History

Parse file secrets with schema

Rhys Sullivan committed 6 months ago

Commit 489f9f61 parent b039b6e1 file · +95 −107Browse files

Unified · Split

packages/plugins/file-secrets/src/index.ts+95−107
@@ -27,11 +27,9 @@ export const xdgDataHome = (): string => { · 26 unmodified lines
27 return path.join(process.env.HOME || "~", ".local", "share");27 return path.join(process.env.HOME || "~", ".local", "share");
28};28};
29 29
30const authDir = (overrideDir?: string): string =>30const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME);
31 overrideDir ?? path.join(xdgDataHome(), APP_NAME);
32 31
33const authFilePath = (overrideDir?: string): string =>32const authFilePath = (overrideDir?: string): string => path.join(authDir(overrideDir), "auth.json");
34 path.join(authDir(overrideDir), "auth.json");
35 33
36// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
37// Schema for the auth file35// Schema for the auth file
@@ -40,59 +38,83 @@ const authFilePath = (overrideDir?: string): string => · 2 unmodified lines
40// { "web-a1b2c3d4": { "github-token": "ghp_xxx" } }38// { "web-a1b2c3d4": { "github-token": "ghp_xxx" } }
41// ---------------------------------------------------------------------------39// ---------------------------------------------------------------------------
42 40
43const ScopedAuthFile = Schema.Record(41const ScopedAuthFile = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.String));
44 Schema.String,42const decodeScopedAuthFile = Schema.decodeUnknownEffect(Schema.fromJsonString(ScopedAuthFile));
45 Schema.Record(Schema.String, Schema.String),
46);
47const decodeScopedAuthFile = Schema.decodeUnknownSync(ScopedAuthFile);
48 43
49// ---------------------------------------------------------------------------44// ---------------------------------------------------------------------------
50// File I/O with restricted permissions45// File I/O with restricted permissions
51//46//
52// These helpers throw on real I/O or decode failures — the provider wraps47// These helpers keep real I/O and decode failures in the Effect error
53// every call in `Effect.try` so those throws surface as typed48// channel as `StorageError`. Missing files are still treated as an empty
54// `StorageError` on the Effect error channel. Previously `readFullFile`49// auth file, but malformed JSON, schema decode failures, and permission
55// used a blanket `try { ... } catch { return {}; }` which masked JSON50// errors no longer collapse into "empty file".
56// parse errors, schema decode failures, and permission errors as
57// "empty file", making misconfigured installs silently return null from
58// every `get`.
59// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
60 52
61const readFullFile = (filePath: string): Record<string, Record<string, string>> => {53const isFileNotFoundCause = (cause: unknown): cause is NodeJS.ErrnoException =>
62 if (!fs.existsSync(filePath)) return {};54 typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT";
63 let raw: string;55
64 try {56const toStorageError =
65 raw = fs.readFileSync(filePath, "utf-8");57 (message: string) =>
66 } catch (cause) {58 (cause: unknown): StorageError =>
67 // Treat "file disappeared between existsSync and readFileSync" as59 new StorageError({ message, cause });
68 // absence — anything else (EACCES, EISDIR, …) propagates.60
69 if ((cause as NodeJS.ErrnoException).code === "ENOENT") return {};61const readFullFile = (
70 throw cause;62 filePath: string,
71 }63): Effect.Effect<Record<string, Record<string, string>>, StorageError> => {
72 return decodeScopedAuthFile(JSON.parse(raw));64 if (!fs.existsSync(filePath)) return Effect.succeed({});
65 return Effect.try({
66 try: () => fs.readFileSync(filePath, "utf-8"),
67 catch: toStorageError("Failed to read auth file"),
68 }).pipe(
69 Effect.catchIf(
70 (error) => isFileNotFoundCause(error.cause),
71 () => Effect.succeed(""),
72 ),
73 Effect.flatMap((raw) =>
74 raw === ""
75 ? Effect.succeed({})
76 : decodeScopedAuthFile(raw).pipe(
77 Effect.mapError(toStorageError("Failed to parse auth file")),
78 ),
79 ),
80 );
73};81};
74 82
75const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> =>83const readScopeSecrets = (
76 readFullFile(filePath)[scopeId] ?? {};84 filePath: string,
85 scopeId: string,
86): Effect.Effect<Record<string, string>, StorageError> =>
87 readFullFile(filePath).pipe(Effect.map((file) => file[scopeId] ?? {}));
77 88
78const writeScopeSecrets = (89const writeScopeSecrets = (
79 filePath: string,90 filePath: string,
80 scopeId: string,91 scopeId: string,
81 secrets: Record<string, string>,92 secrets: Record<string, string>,
82): void => {93): Effect.Effect<void, StorageError> => {
83 const dir = path.dirname(filePath);94 const dir = path.dirname(filePath);
84 if (!fs.existsSync(dir)) {
85 fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
86 }
87 const full = readFullFile(filePath);
88 if (Object.keys(secrets).length === 0) {
89 delete full[scopeId];
90 } else {
91 full[scopeId] = secrets;
92 }
93 const tmp = `${filePath}.tmp`;95 const tmp = `${filePath}.tmp`;
94 fs.writeFileSync(tmp, JSON.stringify(full, null, 2), { mode: 0o600 });96 return Effect.gen(function* () {
95 fs.renameSync(tmp, filePath);97 if (!fs.existsSync(dir)) {
98 yield* Effect.try({
99 try: () => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }),
100 catch: toStorageError("Failed to create auth directory"),
101 });
102 }
103 const full = yield* readFullFile(filePath);
104 if (Object.keys(secrets).length === 0) {
105 delete full[scopeId];
106 } else {
107 full[scopeId] = secrets;
108 }
109 yield* Effect.try({
110 try: () => fs.writeFileSync(tmp, JSON.stringify(full, null, 2), { mode: 0o600 }),
111 catch: toStorageError("Failed to write temporary auth file"),
112 });
113 yield* Effect.try({
114 try: () => fs.renameSync(tmp, filePath),
115 catch: toStorageError("Failed to replace auth file"),
116 });
117 });
96};118};
97 119
98// ---------------------------------------------------------------------------120// ---------------------------------------------------------------------------
@@ -108,21 +130,16 @@ export interface FileSecretsPluginConfig { · 9 unmodified lines
108// Plugin extension — public API on executor.fileSecrets130// Plugin extension — public API on executor.fileSecrets
109// ---------------------------------------------------------------------------131// ---------------------------------------------------------------------------
110 132
111export interface FileSecretsExtension {133const makeFileSecretsExtension = (options: FileSecretsPluginConfig | undefined) => ({
112 /** Path to the auth file */134 filePath: resolveFilePath(options),
113 readonly filePath: string;135});
114}136
137export type FileSecretsExtension = ReturnType<typeof makeFileSecretsExtension>;
115 138
116// ---------------------------------------------------------------------------139// ---------------------------------------------------------------------------
117// Provider factory (internal)140// Provider factory (internal)
118// ---------------------------------------------------------------------------141// ---------------------------------------------------------------------------
119 142
120const toStorageError = (cause: unknown) =>
121 new StorageError({
122 message: cause instanceof Error ? cause.message : String(cause),
123 cause,
124 });
125
126// Scope arg is honored at every call: the auth.json is partitioned by143// Scope arg is honored at every call: the auth.json is partitioned by
127// scope id, so read/write/delete route to `file[scope][secretId]`. The144// scope id, so read/write/delete route to `file[scope][secretId]`. The
128// provider is a singleton per executor; scope routing happens via the145// provider is a singleton per executor; scope routing happens via the
@@ -133,61 +150,36 @@ const toStorageError = (cause: unknown) => · 4 unmodified lines
133// the SecretProvider.list signature is scope-agnostic. That's fine for150// the SecretProvider.list signature is scope-agnostic. That's fine for
134// the current use: `list` feeds `secrets.list()` which already walks151// the current use: `list` feeds `secrets.list()` which already walks
135// the stack at the caller layer. Innermost-first is the display default.152// the stack at the caller layer. Innermost-first is the display default.
136const makeScopedProvider = (153const makeScopedProvider = (filePath: string, listScope: string): SecretProvider => ({
137 filePath: string,
138 listScope: string,
139): SecretProvider => ({
140 key: "file",154 key: "file",
141 writable: true,155 writable: true,
142 156
143 get: (secretId, scope) =>157 get: (secretId, scope) =>
144 Effect.try({158 readScopeSecrets(filePath, scope).pipe(Effect.map((data) => data[secretId] ?? null)),
145 try: () => {
146 const data = readScopeSecrets(filePath, scope);
147 return data[secretId] ?? null;
148 },
149 catch: toStorageError,
150 }),
151 159
152 has: (secretId, scope) =>160 has: (secretId, scope) =>
153 Effect.try({161 readScopeSecrets(filePath, scope).pipe(Effect.map((data) => secretId in data)),
154 try: () => {
155 const data = readScopeSecrets(filePath, scope);
156 return secretId in data;
157 },
158 catch: toStorageError,
159 }),
160 162
161 set: (secretId, value, scope) =>163 set: (secretId, value, scope) =>
162 Effect.try({164 Effect.gen(function* () {
163 try: () => {165 const data = yield* readScopeSecrets(filePath, scope);
164 const data = readScopeSecrets(filePath, scope);166 data[secretId] = value;
165 data[secretId] = value;167 yield* writeScopeSecrets(filePath, scope, data);
166 writeScopeSecrets(filePath, scope, data);
167 },
168 catch: toStorageError,
169 }),168 }),
170 169
171 delete: (secretId, scope) =>170 delete: (secretId, scope) =>
172 Effect.try({171 Effect.gen(function* () {
173 try: () => {172 const data = yield* readScopeSecrets(filePath, scope);
174 const data = readScopeSecrets(filePath, scope);173 const had = secretId in data;
175 const had = secretId in data;174 delete data[secretId];
176 delete data[secretId];175 if (had) yield* writeScopeSecrets(filePath, scope, data);
177 if (had) writeScopeSecrets(filePath, scope, data);176 return had;
178 return had;
179 },
180 catch: toStorageError,
181 }),177 }),
182 178
183 list: () =>179 list: () =>
184 Effect.try({180 readScopeSecrets(filePath, listScope).pipe(
185 try: () => {181 Effect.map((data) => Object.keys(data).map((k) => ({ id: k, name: k }))),
186 const data = readScopeSecrets(filePath, listScope);182 ),
187 return Object.keys(data).map((k) => ({ id: k, name: k }));
188 },
189 catch: toStorageError,
190 }),
191});183});
192 184
193// ---------------------------------------------------------------------------185// ---------------------------------------------------------------------------
@@ -201,19 +193,15 @@ const makeScopedProvider = ( · 7 unmodified lines
201const resolveFilePath = (config: FileSecretsPluginConfig | undefined): string =>193const resolveFilePath = (config: FileSecretsPluginConfig | undefined): string =>
202 authFilePath(config?.directory);194 authFilePath(config?.directory);
203 195
204export const fileSecretsPlugin = definePlugin(196export const fileSecretsPlugin = definePlugin((options?: FileSecretsPluginConfig) => ({
205 (options?: FileSecretsPluginConfig) => ({197 id: "fileSecrets" as const,
206 id: "fileSecrets" as const,198 storage: () => ({}),
207 storage: () => ({}),
208 199
209 extension: (_ctx): FileSecretsExtension => ({200 extension: () => makeFileSecretsExtension(options),
210 filePath: resolveFilePath(options),
211 }),
212 201
213 secretProviders: (ctx: PluginCtx<unknown>) => [202 secretProviders: (ctx: PluginCtx<unknown>) => [
214 // list() falls back to the innermost scope for display; per-call203 // list() falls back to the innermost scope for display; per-call
215 // get/set/delete honor the scope arg threaded from the secrets facade.204 // get/set/delete honor the scope arg threaded from the secrets facade.
216 makeScopedProvider(resolveFilePath(options), ctx.scopes[0]!.id as string),205 makeScopedProvider(resolveFilePath(options), ctx.scopes[0]!.id),
217 ],206 ],
218 }),207}));
219);