fix(mosaic): bind lifecycle across storage and runtime
This commit is contained in:
@@ -529,7 +529,29 @@ get_gitea_token() {
|
||||
esac
|
||||
if [[ -n "$_idpfx" ]]; then
|
||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
||||
if [[ -r "$_idcred" ]]; then
|
||||
local _resolved_token
|
||||
_resolved_token=$(python3 - "$_idcred" <<'PY'
|
||||
import json, sys
|
||||
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
|
||||
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
|
||||
raise SystemExit(1)
|
||||
print(value)
|
||||
PY
|
||||
) || return 1
|
||||
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
|
||||
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
|
||||
"$_ident" "$host" "$_ident_src" >&2
|
||||
fi
|
||||
printf '%s\n' "$_resolved_token"
|
||||
return 0
|
||||
fi
|
||||
if [[ -r "$_idtok" ]]; then
|
||||
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
|
||||
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
|
||||
"$_ident" "$host" "$_ident_src" >&2
|
||||
fi
|
||||
cat "$_idtok"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -46,7 +46,29 @@ if [ -n "$ident" ]; then
|
||||
esac
|
||||
if [ -n "$idpfx" ]; then
|
||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
||||
if [ -r "$idcred" ]; then
|
||||
token=$(python3 - "$idcred" <<'PY'
|
||||
import json, sys
|
||||
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
|
||||
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
|
||||
raise SystemExit(1)
|
||||
print(value)
|
||||
PY
|
||||
) || exit 1
|
||||
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
|
||||
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
|
||||
"$ident" "$host" >&2
|
||||
fi
|
||||
echo "username=${ident}"
|
||||
echo "password=${token}"
|
||||
exit 0
|
||||
fi
|
||||
if [ -r "$idtok" ]; then
|
||||
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
|
||||
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
|
||||
"$ident" "$host" >&2
|
||||
fi
|
||||
echo "username=${ident}"
|
||||
echo "password=$(cat "$idtok")"
|
||||
exit 0
|
||||
|
||||
@@ -462,6 +462,7 @@ export async function executeCredentialRevoke(
|
||||
authority,
|
||||
context.provider,
|
||||
context.store,
|
||||
context.teaStore,
|
||||
{ stateRoot: context.stateRoot, actor: options.actor },
|
||||
);
|
||||
} catch {
|
||||
@@ -660,6 +661,19 @@ export async function executeCredentialGet(
|
||||
});
|
||||
await journal.recordIntent('get-requested');
|
||||
try {
|
||||
if (
|
||||
process.env['MOSAIC_AGENT_NAME'] === undefined ||
|
||||
process.env['MOSAIC_AGENT_NAME'] !== identity ||
|
||||
options.actor !== identity
|
||||
) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'Runtime fleet identity, explicit actor, and requested identity must match.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
const fd = Number(options.outputFd);
|
||||
const stat = fstatSync(fd);
|
||||
if (
|
||||
@@ -687,6 +701,7 @@ export async function executeCredentialGet(
|
||||
const prefix = new TextEncoder().encode(
|
||||
`protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`,
|
||||
);
|
||||
await journal.recordMutation('credential-issuance-authorized');
|
||||
writeSync(fd, prefix);
|
||||
writeSync(fd, resolved.secret);
|
||||
writeSync(fd, new TextEncoder().encode('\n\n'));
|
||||
|
||||
@@ -48,10 +48,12 @@ const SAFE_DECISIONS = new Set<string>([
|
||||
'token-mint-applied',
|
||||
'token-binding-stored',
|
||||
'tea-login-stored',
|
||||
'tea-login-removed',
|
||||
'provision-rollback-verified',
|
||||
'rotate-rollback-verified',
|
||||
'token-revoke-applied',
|
||||
'wire-applied',
|
||||
'credential-issuance-authorized',
|
||||
'credential-issued',
|
||||
'classification-correction',
|
||||
]);
|
||||
|
||||
@@ -30,6 +30,9 @@ const bindingSchema = z
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
const credentialEnvelopeSchema = bindingSchema.extend({
|
||||
token: z.string().min(1).max(MAX_TOKEN_BYTES).regex(/^\S+$/),
|
||||
});
|
||||
|
||||
export class CredentialStoreError extends Error {
|
||||
constructor(
|
||||
@@ -102,6 +105,47 @@ export class FileCredentialResolver implements CredentialResolver {
|
||||
);
|
||||
}
|
||||
|
||||
const envelopePath = join(
|
||||
this.tokenDirectory,
|
||||
`${hostConfig.tokenPrefix}-${identity}.credential.json`,
|
||||
);
|
||||
try {
|
||||
const envelopeSnapshot = readRegularFileSecure(envelopePath, {
|
||||
root: this.tokenDirectory,
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
const envelope = credentialEnvelopeSchema.safeParse(
|
||||
JSON.parse(envelopeSnapshot.content.toString('utf8')),
|
||||
);
|
||||
if (
|
||||
!envelope.success ||
|
||||
envelopeSnapshot.uid !== process.getuid?.() ||
|
||||
(envelopeSnapshot.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'invalid-binding',
|
||||
'credential envelope failed schema, owner, or mode validation',
|
||||
);
|
||||
}
|
||||
const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
|
||||
const digest = createHash('sha256').update(secret).digest('hex');
|
||||
if (envelope.data.tokenDigest !== digest) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'credential envelope digest does not match its token',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
identity,
|
||||
estate,
|
||||
host,
|
||||
resolutionId: randomUUID(),
|
||||
secret,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
}
|
||||
|
||||
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
|
||||
let snapshot: SecureFileSnapshot;
|
||||
try {
|
||||
@@ -182,6 +226,7 @@ export class FileCredentialStore {
|
||||
): {
|
||||
readonly token: string;
|
||||
readonly binding: string;
|
||||
readonly envelope: string;
|
||||
readonly prefix: string;
|
||||
} {
|
||||
if (!IDENTITY.test(identity)) {
|
||||
@@ -198,6 +243,7 @@ export class FileCredentialStore {
|
||||
return {
|
||||
token: join(this.tokenDirectory, `${prefix}.token`),
|
||||
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
|
||||
envelope: join(this.tokenDirectory, `${prefix}.credential.json`),
|
||||
prefix,
|
||||
};
|
||||
}
|
||||
@@ -206,14 +252,14 @@ export class FileCredentialStore {
|
||||
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
||||
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
||||
const token = validateSecret(Buffer.from(secret));
|
||||
const binding = bindingSchema.parse({
|
||||
const envelope = credentialEnvelopeSchema.parse({
|
||||
...metadata,
|
||||
schemaVersion: 1,
|
||||
tokenDigest: createHash('sha256').update(token).digest('hex'),
|
||||
token: Buffer.from(token).toString('utf8'),
|
||||
});
|
||||
const suffix = randomUUID();
|
||||
const tokenTemp = `${paths.token}.${suffix}.tmp`;
|
||||
const bindingTemp = `${paths.binding}.${suffix}.tmp`;
|
||||
const envelopeTemp = `${paths.envelope}.${suffix}.tmp`;
|
||||
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
||||
let lock;
|
||||
try {
|
||||
@@ -224,23 +270,19 @@ export class FileCredentialStore {
|
||||
'another mutation owns the identity lock',
|
||||
);
|
||||
}
|
||||
const tokenHandle = await open(tokenTemp, 'wx', 0o600);
|
||||
const bindingHandle = await open(bindingTemp, 'wx', 0o600);
|
||||
try {
|
||||
await tokenHandle.writeFile(token);
|
||||
await tokenHandle.sync();
|
||||
await bindingHandle.writeFile(`${JSON.stringify(binding)}\n`, 'utf8');
|
||||
await bindingHandle.sync();
|
||||
} finally {
|
||||
await tokenHandle.close();
|
||||
await bindingHandle.close();
|
||||
}
|
||||
try {
|
||||
await rename(bindingTemp, paths.binding);
|
||||
await rename(tokenTemp, paths.token);
|
||||
const handle = await open(envelopeTemp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(envelopeTemp, paths.envelope);
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
} finally {
|
||||
await lock.close();
|
||||
await unlink(envelopeTemp).catch((): void => undefined);
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
}
|
||||
@@ -279,6 +321,31 @@ export class FileCredentialStore {
|
||||
): Promise<CredentialBindingMetadataDto | undefined> {
|
||||
const paths = this.paths(identity, estate, host);
|
||||
let snapshot: SecureFileSnapshot;
|
||||
try {
|
||||
const envelope = readRegularFileSecure(paths.envelope, {
|
||||
root: this.tokenDirectory,
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
const parsed = credentialEnvelopeSchema.safeParse(
|
||||
JSON.parse(envelope.content.toString('utf8')),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed');
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
identity: parsed.data.identity,
|
||||
estate: parsed.data.estate,
|
||||
host: parsed.data.host,
|
||||
providerLogin: parsed.data.providerLogin,
|
||||
tokenName: parsed.data.tokenName,
|
||||
scopes: parsed.data.scopes,
|
||||
createdAt: parsed.data.createdAt,
|
||||
tokenDigest: parsed.data.tokenDigest,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
}
|
||||
try {
|
||||
snapshot = readRegularFileSecure(paths.binding, {
|
||||
root: this.tokenDirectory,
|
||||
@@ -306,9 +373,20 @@ export class FileCredentialStore {
|
||||
if (config === undefined) return [];
|
||||
const names = await readdir(this.tokenDirectory);
|
||||
const prefix = `${config.tokenPrefix}-`;
|
||||
return names
|
||||
.filter((name): boolean => name.startsWith(prefix) && name.endsWith('.token'))
|
||||
.map((name): string => name.slice(prefix.length, -'.token'.length))
|
||||
return [
|
||||
...new Set(
|
||||
names.flatMap((name): string[] => {
|
||||
if (!name.startsWith(prefix)) return [];
|
||||
if (name.endsWith('.token')) {
|
||||
return [name.slice(prefix.length, -'.token'.length)];
|
||||
}
|
||||
if (name.endsWith('.credential.json')) {
|
||||
return [name.slice(prefix.length, -'.credential.json'.length)];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
),
|
||||
]
|
||||
.filter((identity): boolean => IDENTITY.test(identity))
|
||||
.sort();
|
||||
}
|
||||
@@ -321,6 +399,9 @@ export class FileCredentialStore {
|
||||
await unlink(paths.binding).catch((error: unknown): void => {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
});
|
||||
await unlink(paths.envelope).catch((error: unknown): void => {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
});
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ describe('credential lifecycle', (): void => {
|
||||
authority,
|
||||
lifecycleProvider,
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
expect(result.outcome).toBe('ok');
|
||||
@@ -203,6 +204,7 @@ describe('credential lifecycle', (): void => {
|
||||
authority,
|
||||
lifecycleProvider,
|
||||
store,
|
||||
teaStore,
|
||||
{ stateRoot: root, actor: 'seat' },
|
||||
);
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@ export async function revokeCredential(
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaLifecycleProvider,
|
||||
store: FileCredentialStore,
|
||||
teaStore: TeaLoginStore,
|
||||
options: LifecycleOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const journal = await openLifecycleJournal('revoke', request, options);
|
||||
@@ -292,6 +293,11 @@ export async function revokeCredential(
|
||||
auditState: 'sealed',
|
||||
});
|
||||
}
|
||||
await teaStore.remove(request.identity, request.host);
|
||||
if (teaStore.readBack(request.identity, request.host) !== undefined) {
|
||||
throw new Error('Tea login still exists after revocation');
|
||||
}
|
||||
await journal.recordMutation('tea-login-removed');
|
||||
await store.remove(request.identity, request.estate, request.host);
|
||||
await journal.seal('ok', 'revoke-verified');
|
||||
return lifecycleResult('revoke', request, {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { TeaLoginStore } from './tea-login-store.js';
|
||||
|
||||
let root: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
describe('host-bound Tea login store', (): void => {
|
||||
it('serializes concurrent updates and preserves the same identity on two hosts', async (): Promise<void> => {
|
||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||
const store = new TeaLoginStore(join(root, 'tea', 'config.yml'));
|
||||
await Promise.all([
|
||||
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
||||
store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
|
||||
]);
|
||||
|
||||
expect(
|
||||
store.matchesSecret('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
store.matchesSecret('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
|
||||
).toBe(true);
|
||||
await store.remove('seat', 'git.one.invalid');
|
||||
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
|
||||
expect(store.readBack('seat', 'git.two.invalid')).toEqual({
|
||||
name: 'seat',
|
||||
host: 'git.two.invalid',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { open, rename } from 'node:fs/promises';
|
||||
import { open, rename, unlink } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { parse, stringify } from 'yaml';
|
||||
import { z } from 'zod';
|
||||
@@ -41,6 +41,23 @@ const loginSchema = z
|
||||
.passthrough();
|
||||
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
|
||||
|
||||
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
try {
|
||||
return await open(path, 'wx', 0o600);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
|
||||
await new Promise<void>((resolve): void => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new TeaLoginStoreError(
|
||||
'conflicting-credential-mutation',
|
||||
'Tea configuration lock did not become available',
|
||||
);
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
@@ -54,42 +71,51 @@ export class TeaLoginStore {
|
||||
}
|
||||
const directory = dirname(this.configPath);
|
||||
ensureManagedDirectory(directory, directory);
|
||||
let current: TeaConfig = { logins: [] };
|
||||
const lockPath = `${this.configPath}.lock`;
|
||||
const lock = await acquireLock(lockPath);
|
||||
try {
|
||||
const snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
||||
let current: TeaConfig = { logins: [] };
|
||||
try {
|
||||
const snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
||||
}
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
current = { logins: decoded.data.logins };
|
||||
} catch (error: unknown) {
|
||||
if (!missing(error)) throw error;
|
||||
}
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
const token = Buffer.from(secret).toString('utf8');
|
||||
const record: TeaLoginRecord = {
|
||||
name: identity,
|
||||
url: `https://${host}`,
|
||||
token,
|
||||
user: identity,
|
||||
default: false,
|
||||
};
|
||||
const logins = current.logins.filter(
|
||||
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
|
||||
);
|
||||
logins.push(record);
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
current = { logins: decoded.data.logins };
|
||||
} catch (error: unknown) {
|
||||
if (!missing(error)) throw error;
|
||||
}
|
||||
const token = Buffer.from(secret).toString('utf8');
|
||||
const record: TeaLoginRecord = {
|
||||
name: identity,
|
||||
url: `https://${host}`,
|
||||
token,
|
||||
user: identity,
|
||||
default: false,
|
||||
};
|
||||
const logins = current.logins.filter((login): boolean => login.name !== identity);
|
||||
logins.push(record);
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
|
||||
await handle.sync();
|
||||
await rename(temp, this.configPath);
|
||||
} finally {
|
||||
await handle.close();
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
await rename(temp, this.configPath);
|
||||
}
|
||||
|
||||
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
|
||||
@@ -128,26 +154,33 @@ export class TeaLoginStore {
|
||||
|
||||
async remove(identity: string, host: string): Promise<void> {
|
||||
const directory = dirname(this.configPath);
|
||||
const snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
const logins = decoded.data.logins.filter(
|
||||
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
|
||||
);
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
const lockPath = `${this.configPath}.lock`;
|
||||
const lock = await acquireLock(lockPath);
|
||||
try {
|
||||
await handle.writeFile(stringify({ logins }), 'utf8');
|
||||
await handle.sync();
|
||||
const snapshot = readRegularFileSecure(this.configPath, {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
}
|
||||
const logins = decoded.data.logins.filter(
|
||||
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
|
||||
);
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify({ logins }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temp, this.configPath);
|
||||
} finally {
|
||||
await handle.close();
|
||||
await lock.close();
|
||||
await unlink(lockPath).catch((): void => undefined);
|
||||
}
|
||||
await rename(temp, this.configPath);
|
||||
}
|
||||
|
||||
readBack(
|
||||
|
||||
Reference in New Issue
Block a user