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
2727 return path.join(process.env.HOME || "~", ".local", "share");
2828 };
2929
30−const authDir = (overrideDir?: string): string =>
31− overrideDir ?? path.join(xdgDataHome(), APP_NAME);
30+const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME);
3231
33−const authFilePath = (overrideDir?: string): string =>
34− path.join(authDir(overrideDir), "auth.json");
32+const authFilePath = (overrideDir?: string): string => path.join(authDir(overrideDir), "auth.json");
3533
3634 // ---------------------------------------------------------------------------
3735 // Schema for the auth file
@@ -40,59 +38,83 @@ const authFilePath = (overrideDir?: string): string => · 2 unmodified lines
4038 // { "web-a1b2c3d4": { "github-token": "ghp_xxx" } }
4139 // ---------------------------------------------------------------------------
4240
43−const ScopedAuthFile = Schema.Record(
44− Schema.String,
45− Schema.Record(Schema.String, Schema.String),
46−);
47−const decodeScopedAuthFile = Schema.decodeUnknownSync(ScopedAuthFile);
41+const ScopedAuthFile = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.String));
42+const decodeScopedAuthFile = Schema.decodeUnknownEffect(Schema.fromJsonString(ScopedAuthFile));
4843
4944 // ---------------------------------------------------------------------------
5045 // File I/O with restricted permissions
5146 //
52−// These helpers throw on real I/O or decode failures — the provider wraps
53−// every call in `Effect.try` so those throws surface as typed
54−// `StorageError` on the Effect error channel. Previously `readFullFile`
55−// used a blanket `try { ... } catch { return {}; }` which masked JSON
56−// parse errors, schema decode failures, and permission errors as
57−// "empty file", making misconfigured installs silently return null from
58−// every `get`.
47+// These helpers keep real I/O and decode failures in the Effect error
48+// channel as `StorageError`. Missing files are still treated as an empty
49+// auth file, but malformed JSON, schema decode failures, and permission
50+// errors no longer collapse into "empty file".
5951 // ---------------------------------------------------------------------------
6052
61−const readFullFile = (filePath: string): Record<string, Record<string, string>> => {
62− if (!fs.existsSync(filePath)) return {};
63− let raw: string;
64− try {
65− raw = fs.readFileSync(filePath, "utf-8");
66− } catch (cause) {
67− // Treat "file disappeared between existsSync and readFileSync" as
68− // absence — anything else (EACCES, EISDIR, …) propagates.
69− if ((cause as NodeJS.ErrnoException).code === "ENOENT") return {};
70− throw cause;
71− }
72− return decodeScopedAuthFile(JSON.parse(raw));
53+const isFileNotFoundCause = (cause: unknown): cause is NodeJS.ErrnoException =>
54+ typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT";
55+
56+const toStorageError =
57+ (message: string) =>
58+ (cause: unknown): StorageError =>
59+ new StorageError({ message, cause });
60+
61+const readFullFile = (
62+ filePath: string,
63+): Effect.Effect<Record<string, Record<string, string>>, StorageError> => {
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+ );
7381 };
7482
75−const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> =>
76− readFullFile(filePath)[scopeId] ?? {};
83+const readScopeSecrets = (
84+ filePath: string,
85+ scopeId: string,
86+): Effect.Effect<Record<string, string>, StorageError> =>
87+ readFullFile(filePath).pipe(Effect.map((file) => file[scopeId] ?? {}));
7788
7889 const writeScopeSecrets = (
7990 filePath: string,
8091 scopeId: string,
8192 secrets: Record<string, string>,
82−): void => {
93+): Effect.Effect<void, StorageError> => {
8394 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− }
9395 const tmp = `${filePath}.tmp`;
94− fs.writeFileSync(tmp, JSON.stringify(full, null, 2), { mode: 0o600 });
95− fs.renameSync(tmp, filePath);
96+ return Effect.gen(function* () {
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+ });
96118 };
97119
98120 // ---------------------------------------------------------------------------
@@ -108,21 +130,16 @@ export interface FileSecretsPluginConfig { · 9 unmodified lines
108130 // Plugin extension — public API on executor.fileSecrets
109131 // ---------------------------------------------------------------------------
110132
111−export interface FileSecretsExtension {
112− /** Path to the auth file */
113− readonly filePath: string;
114−}
133+const makeFileSecretsExtension = (options: FileSecretsPluginConfig | undefined) => ({
134+ filePath: resolveFilePath(options),
135+});
136+
137+export type FileSecretsExtension = ReturnType<typeof makeFileSecretsExtension>;
115138
116139 // ---------------------------------------------------------------------------
117140 // Provider factory (internal)
118141 // ---------------------------------------------------------------------------
119142
120−const toStorageError = (cause: unknown) =>
121− new StorageError({
122− message: cause instanceof Error ? cause.message : String(cause),
123− cause,
124− });
125−
126143 // Scope arg is honored at every call: the auth.json is partitioned by
127144 // scope id, so read/write/delete route to `file[scope][secretId]`. The
128145 // provider is a singleton per executor; scope routing happens via the
@@ -133,61 +150,36 @@ const toStorageError = (cause: unknown) => · 4 unmodified lines
133150 // the SecretProvider.list signature is scope-agnostic. That's fine for
134151 // the current use: `list` feeds `secrets.list()` which already walks
135152 // the stack at the caller layer. Innermost-first is the display default.
136−const makeScopedProvider = (
137− filePath: string,
138− listScope: string,
139−): SecretProvider => ({
153+const makeScopedProvider = (filePath: string, listScope: string): SecretProvider => ({
140154 key: "file",
141155 writable: true,
142156
143157 get: (secretId, scope) =>
144− Effect.try({
145− try: () => {
146− const data = readScopeSecrets(filePath, scope);
147− return data[secretId] ?? null;
148− },
149− catch: toStorageError,
150− }),
158+ readScopeSecrets(filePath, scope).pipe(Effect.map((data) => data[secretId] ?? null)),
151159
152160 has: (secretId, scope) =>
153− Effect.try({
154− try: () => {
155− const data = readScopeSecrets(filePath, scope);
156− return secretId in data;
157− },
158− catch: toStorageError,
159− }),
161+ readScopeSecrets(filePath, scope).pipe(Effect.map((data) => secretId in data)),
160162
161163 set: (secretId, value, scope) =>
162− Effect.try({
163− try: () => {
164− const data = readScopeSecrets(filePath, scope);
165− data[secretId] = value;
166− writeScopeSecrets(filePath, scope, data);
167− },
168− catch: toStorageError,
164+ Effect.gen(function* () {
165+ const data = yield* readScopeSecrets(filePath, scope);
166+ data[secretId] = value;
167+ yield* writeScopeSecrets(filePath, scope, data);
169168 }),
170169
171170 delete: (secretId, scope) =>
172− Effect.try({
173− try: () => {
174− const data = readScopeSecrets(filePath, scope);
175− const had = secretId in data;
176− delete data[secretId];
177− if (had) writeScopeSecrets(filePath, scope, data);
178− return had;
179− },
180− catch: toStorageError,
171+ Effect.gen(function* () {
172+ const data = yield* readScopeSecrets(filePath, scope);
173+ const had = secretId in data;
174+ delete data[secretId];
175+ if (had) yield* writeScopeSecrets(filePath, scope, data);
176+ return had;
181177 }),
182178
183179 list: () =>
184− Effect.try({
185− try: () => {
186− const data = readScopeSecrets(filePath, listScope);
187− return Object.keys(data).map((k) => ({ id: k, name: k }));
188− },
189− catch: toStorageError,
190− }),
180+ readScopeSecrets(filePath, listScope).pipe(
181+ Effect.map((data) => Object.keys(data).map((k) => ({ id: k, name: k }))),
182+ ),
191183 });
192184
193185 // ---------------------------------------------------------------------------
@@ -201,19 +193,15 @@ const makeScopedProvider = ( · 7 unmodified lines
201193 const resolveFilePath = (config: FileSecretsPluginConfig | undefined): string =>
202194 authFilePath(config?.directory);
203195
204−export const fileSecretsPlugin = definePlugin(
205− (options?: FileSecretsPluginConfig) => ({
206− id: "fileSecrets" as const,
207− storage: () => ({}),
196+export const fileSecretsPlugin = definePlugin((options?: FileSecretsPluginConfig) => ({
197+ id: "fileSecrets" as const,
198+ storage: () => ({}),
208199
209− extension: (_ctx): FileSecretsExtension => ({
210− filePath: resolveFilePath(options),
211− }),
200+ extension: () => makeFileSecretsExtension(options),
212201
213− secretProviders: (ctx: PluginCtx<unknown>) => [
214− // list() falls back to the innermost scope for display; per-call
215− // get/set/delete honor the scope arg threaded from the secrets facade.
216− makeScopedProvider(resolveFilePath(options), ctx.scopes[0]!.id as string),
217− ],
218− }),
219−);
202+ secretProviders: (ctx: PluginCtx<unknown>) => [
203+ // list() falls back to the innermost scope for display; per-call
204+ // get/set/delete honor the scope arg threaded from the secrets facade.
205+ makeScopedProvider(resolveFilePath(options), ctx.scopes[0]!.id),
206+ ],
207+}));