@homeflare/alchemy 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/github/fork-pr-approval.d.ts +55 -0
- package/dist/github/fork-pr-approval.d.ts.map +1 -0
- package/dist/github/index.d.ts +1 -0
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +87 -4
- package/dist/github/index.js.map +6 -5
- package/dist/github/repo-baseline-data.d.ts +3 -2
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repository-ruleset-providers.d.ts +1 -1
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -1
- package/dist/linux/container-env-quote.d.ts +2 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -1
- package/dist/linux/index.js +5 -1
- package/dist/linux/index.js.map +3 -3
- package/package.json +1 -1
- package/src/github/fork-pr-approval.test.ts +74 -0
- package/src/github/fork-pr-approval.ts +174 -0
- package/src/github/index.ts +9 -0
- package/src/github/repo-baseline-data.ts +2 -2
- package/src/github/repository-ruleset-providers.ts +6 -1
- package/src/linux/container-env-quote.test.ts +54 -2
- package/src/linux/container-env-quote.ts +15 -0
- package/src/linux/container-fixture.test.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import * as Effect from 'effect/Effect';
|
|
3
|
+
import {
|
|
4
|
+
type ForkPrApprovalClient,
|
|
5
|
+
type ForkPrApprovalPolicy,
|
|
6
|
+
ForkPrApprovalReadbackMismatch,
|
|
7
|
+
forkPrApprovalHandlers,
|
|
8
|
+
} from './fork-pr-approval.ts';
|
|
9
|
+
|
|
10
|
+
const fake = (initial: string, store = true) => {
|
|
11
|
+
const state = { policy: initial, puts: [] as string[] };
|
|
12
|
+
const client: ForkPrApprovalClient = {
|
|
13
|
+
get: () => Effect.succeed(state.policy),
|
|
14
|
+
put: (_o, _r, policy: ForkPrApprovalPolicy) =>
|
|
15
|
+
Effect.sync(() => {
|
|
16
|
+
state.puts.push(policy);
|
|
17
|
+
if (store) state.policy = policy;
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
return { state, handlers: forkPrApprovalHandlers(() => client) };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const props = {
|
|
24
|
+
owner: 'taslabs-net',
|
|
25
|
+
repository: 'homeflare-kit',
|
|
26
|
+
approvalPolicy: 'all_external_contributors',
|
|
27
|
+
} as const;
|
|
28
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
29
|
+
const run = <A>(e: Effect.Effect<A, unknown, any>) => Effect.runPromise(e as Effect.Effect<A>);
|
|
30
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
31
|
+
const h = (x: ReturnType<typeof fake>) => x.handlers as any;
|
|
32
|
+
|
|
33
|
+
describe('GitHub.ForkPrApproval', () => {
|
|
34
|
+
test('reconcile PUTs a looser live policy up to the declared one and reads it back', async () => {
|
|
35
|
+
const f = fake('first_time_contributors');
|
|
36
|
+
const out = await run(h(f).reconcile({ news: props, output: undefined }));
|
|
37
|
+
expect(f.state.puts).toEqual(['all_external_contributors']);
|
|
38
|
+
expect(out).toEqual({ ...props, approvalPolicy: 'all_external_contributors' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('reconcile writes nothing when live already matches', async () => {
|
|
42
|
+
const f = fake('all_external_contributors');
|
|
43
|
+
await run(h(f).reconcile({ news: props, output: undefined }));
|
|
44
|
+
expect(f.state.puts).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a PUT GitHub does not store fails the reconcile', async () => {
|
|
48
|
+
const f = fake('first_time_contributors', false);
|
|
49
|
+
const err = await run(h(f).reconcile({ news: props, output: undefined })).catch(
|
|
50
|
+
(e: unknown) => e,
|
|
51
|
+
);
|
|
52
|
+
expect(String(err)).toContain('read back first_time_contributors');
|
|
53
|
+
expect(ForkPrApprovalReadbackMismatch.name).toBe('ForkPrApprovalReadbackMismatch');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an unknown policy is refused before any write', async () => {
|
|
57
|
+
const f = fake('first_time_contributors');
|
|
58
|
+
const bad = { ...props, approvalPolicy: 'nobody' };
|
|
59
|
+
await expect(run(h(f).reconcile({ news: bad, output: undefined }))).rejects.toThrow('unknown');
|
|
60
|
+
expect(f.state.puts).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('delete never touches the live setting', async () => {
|
|
64
|
+
const f = fake('all_external_contributors');
|
|
65
|
+
await run(h(f).delete({ olds: props, output: props }));
|
|
66
|
+
expect(f.state.puts).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('read with no state reports the live value as unowned (adopt on purpose)', async () => {
|
|
70
|
+
const f = fake('first_time_contributors');
|
|
71
|
+
const r = await run(h(f).read({ olds: props, output: undefined }));
|
|
72
|
+
expect(JSON.stringify(r)).toContain('first_time_contributors');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GitHub.ForkPrApproval` — a repository's "approval for running fork pull request workflows
|
|
3
|
+
* from contributors" setting, as a declared resource.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY IT EXISTS (Tim, 2026-10-14 01:28 ET). `PUBLIC_ONLY_UNDECLARABLE` (repo-baseline-data.ts)
|
|
6
|
+
* listed this setting as something no resource could express. homeflare-kit moved onto the
|
|
7
|
+
* self-hosted pve pool, and homeflare-mini's ci-runner serves a public repo only while this
|
|
8
|
+
* setting is `all_external_contributors` (its `PUBLIC_RUNNER_REPOS`, fail-closed) — a fork's
|
|
9
|
+
* code must not reach the LAN runner without a maintainer's click. So the setting is now code.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ ONE ENDPOINT PAIR, NOTHING ELSE. `GET`/`PUT /repos/{owner}/{repo}/actions/permissions/
|
|
12
|
+
* fork-pr-contributor-approval`. The setting always exists, so there is no create and no delete:
|
|
13
|
+
* `reconcile` PUTs the declared policy and reads it back; `delete` leaves the live setting as it
|
|
14
|
+
* is (the default removal policy is `retain`, and dropping a declaration must never loosen it).
|
|
15
|
+
* ⛔ ADOPT, NEVER CLAIM SILENTLY. With no prior state `read` returns the live value as `Unowned`,
|
|
16
|
+
* so a stack has to `adopt(true)` it on purpose — the same convention as `RepositoryRuleset`.
|
|
17
|
+
* ⛔ READ BACK. A PUT that GitHub accepts but does not store is reported, not trusted: the
|
|
18
|
+
* reconcile re-reads and fails on a mismatch.
|
|
19
|
+
*/
|
|
20
|
+
import { Resource } from 'alchemy';
|
|
21
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
22
|
+
import { isResolved } from 'alchemy/Diff';
|
|
23
|
+
import { GitHubCredentials } from 'alchemy/GitHub';
|
|
24
|
+
import * as Provider from 'alchemy/Provider';
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
|
|
27
|
+
/** GitHub's three values, most to least permissive. */
|
|
28
|
+
export const FORK_PR_APPROVAL_POLICIES = [
|
|
29
|
+
'first_time_contributors_new_to_github',
|
|
30
|
+
'first_time_contributors',
|
|
31
|
+
'all_external_contributors',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type ForkPrApprovalPolicy = (typeof FORK_PR_APPROVAL_POLICIES)[number];
|
|
35
|
+
|
|
36
|
+
export interface ForkPrApprovalProps {
|
|
37
|
+
readonly owner: string;
|
|
38
|
+
readonly repository: string;
|
|
39
|
+
readonly approvalPolicy: ForkPrApprovalPolicy;
|
|
40
|
+
/** GitHub Enterprise API base; omit for github.com. */
|
|
41
|
+
readonly baseUrl?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ForkPrApprovalAttributes {
|
|
45
|
+
readonly owner: string;
|
|
46
|
+
readonly repository: string;
|
|
47
|
+
readonly approvalPolicy: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ForkPrApproval extends Resource<
|
|
51
|
+
'GitHub.ForkPrApproval',
|
|
52
|
+
ForkPrApprovalProps,
|
|
53
|
+
ForkPrApprovalAttributes,
|
|
54
|
+
never
|
|
55
|
+
> {}
|
|
56
|
+
|
|
57
|
+
export const ForkPrApproval = Resource<ForkPrApproval>('GitHub.ForkPrApproval', {
|
|
58
|
+
defaultRemovalPolicy: 'retain',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** The two calls this resource makes, so tests can hand it a fake. */
|
|
62
|
+
export interface ForkPrApprovalClient<R = never> {
|
|
63
|
+
readonly get: (owner: string, repo: string) => Effect.Effect<string, Error, R>;
|
|
64
|
+
readonly put: (
|
|
65
|
+
owner: string,
|
|
66
|
+
repo: string,
|
|
67
|
+
policy: ForkPrApprovalPolicy,
|
|
68
|
+
) => Effect.Effect<void, Error, R>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A PUT GitHub accepted but did not store. */
|
|
72
|
+
export class ForkPrApprovalReadbackMismatch extends Error {
|
|
73
|
+
readonly _tag = 'ForkPrApprovalReadbackMismatch';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const asError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)));
|
|
77
|
+
const PATH = 'actions/permissions/fork-pr-contributor-approval';
|
|
78
|
+
|
|
79
|
+
export const octokitForkPrApprovalClient = (
|
|
80
|
+
baseUrl: string | undefined,
|
|
81
|
+
): ForkPrApprovalClient<GitHubCredentials> => {
|
|
82
|
+
const octokit = Effect.gen(function* () {
|
|
83
|
+
const creds = yield* yield* GitHubCredentials;
|
|
84
|
+
return baseUrl === undefined ? creds.octokit() : creds.octokit({ baseUrl });
|
|
85
|
+
}).pipe(Effect.mapError(asError));
|
|
86
|
+
return {
|
|
87
|
+
get: (owner, repo) =>
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
const o = yield* octokit;
|
|
90
|
+
const res = yield* Effect.tryPromise({
|
|
91
|
+
try: () => o.request(`GET /repos/{owner}/{repo}/${PATH}`, { owner, repo }),
|
|
92
|
+
catch: asError,
|
|
93
|
+
});
|
|
94
|
+
return String((res.data as { approval_policy?: unknown }).approval_policy);
|
|
95
|
+
}),
|
|
96
|
+
put: (owner, repo, policy) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const o = yield* octokit;
|
|
99
|
+
yield* Effect.tryPromise({
|
|
100
|
+
try: () =>
|
|
101
|
+
o.request(`PUT /repos/{owner}/{repo}/${PATH}`, {
|
|
102
|
+
owner,
|
|
103
|
+
repo,
|
|
104
|
+
approval_policy: policy,
|
|
105
|
+
}),
|
|
106
|
+
catch: asError,
|
|
107
|
+
});
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const attrs = (
|
|
113
|
+
owner: string,
|
|
114
|
+
repository: string,
|
|
115
|
+
approvalPolicy: string,
|
|
116
|
+
): ForkPrApprovalAttributes => ({
|
|
117
|
+
owner,
|
|
118
|
+
repository,
|
|
119
|
+
approvalPolicy,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** The provider's handlers over any client — the real one below, a fake in tests. */
|
|
123
|
+
export const forkPrApprovalHandlers = <R>(
|
|
124
|
+
clientFor: (baseUrl: string | undefined) => ForkPrApprovalClient<R>,
|
|
125
|
+
) =>
|
|
126
|
+
ForkPrApproval.Provider.of({
|
|
127
|
+
list: () => Effect.succeed([]),
|
|
128
|
+
nuke: { skip: true },
|
|
129
|
+
|
|
130
|
+
read: ({ output, olds }) =>
|
|
131
|
+
Effect.gen(function* () {
|
|
132
|
+
const live = yield* clientFor(olds.baseUrl).get(olds.owner, olds.repository);
|
|
133
|
+
const found = attrs(olds.owner, olds.repository, live);
|
|
134
|
+
return output === undefined ? Unowned(found) : found;
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
diff: ({ news, olds }) =>
|
|
138
|
+
Effect.succeed(
|
|
139
|
+
isResolved(news) &&
|
|
140
|
+
olds !== undefined &&
|
|
141
|
+
(news.owner !== olds.owner || news.repository !== olds.repository)
|
|
142
|
+
? ({ action: 'replace' } as const)
|
|
143
|
+
: undefined,
|
|
144
|
+
),
|
|
145
|
+
|
|
146
|
+
reconcile: ({ news }) =>
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
if (!(FORK_PR_APPROVAL_POLICIES as readonly string[]).includes(news.approvalPolicy)) {
|
|
149
|
+
return yield* Effect.fail(
|
|
150
|
+
new Error(`unknown fork-PR approval policy: ${String(news.approvalPolicy)}`),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const client = clientFor(news.baseUrl);
|
|
154
|
+
const before = yield* client.get(news.owner, news.repository);
|
|
155
|
+
if (before !== news.approvalPolicy) {
|
|
156
|
+
yield* client.put(news.owner, news.repository, news.approvalPolicy);
|
|
157
|
+
}
|
|
158
|
+
const after = yield* client.get(news.owner, news.repository);
|
|
159
|
+
if (after !== news.approvalPolicy) {
|
|
160
|
+
return yield* Effect.fail(
|
|
161
|
+
new ForkPrApprovalReadbackMismatch(
|
|
162
|
+
`${news.owner}/${news.repository}: PUT ${news.approvalPolicy}, read back ${after}`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return attrs(news.owner, news.repository, after);
|
|
167
|
+
}),
|
|
168
|
+
|
|
169
|
+
// ⛔ Retain: removing the declaration never loosens the live setting.
|
|
170
|
+
delete: () => Effect.void,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export const ForkPrApprovalProvider = () =>
|
|
174
|
+
Provider.succeed(ForkPrApproval, forkPrApprovalHandlers(octokitForkPrApprovalClient));
|
package/src/github/index.ts
CHANGED
|
@@ -45,6 +45,15 @@ export {
|
|
|
45
45
|
type RepositoryRulesetRules,
|
|
46
46
|
type RepositoryRulesetStatusChecksRule,
|
|
47
47
|
} from './repository-ruleset.ts';
|
|
48
|
+
export {
|
|
49
|
+
FORK_PR_APPROVAL_POLICIES,
|
|
50
|
+
ForkPrApproval,
|
|
51
|
+
type ForkPrApprovalAttributes,
|
|
52
|
+
type ForkPrApprovalPolicy,
|
|
53
|
+
type ForkPrApprovalProps,
|
|
54
|
+
ForkPrApprovalProvider,
|
|
55
|
+
ForkPrApprovalReadbackMismatch,
|
|
56
|
+
} from './fork-pr-approval.ts';
|
|
48
57
|
export { repoPolicyProviders } from './repository-ruleset-providers.ts';
|
|
49
58
|
export { RULE_TYPE_COVERAGE, type RepositoryRuleType } from './repository-ruleset-constraints.ts';
|
|
50
59
|
export {
|
|
@@ -86,10 +86,10 @@ export function repoBaselineRuleset(input: RepoBaselineInput) {
|
|
|
86
86
|
/** Public-only settings this baseline asserts but cannot express through either resource above
|
|
87
87
|
* at `alchemy@2.0.0-beta.79` — recorded for the wave-3 handoff artifact (plan section 2), not
|
|
88
88
|
* declared anywhere. Both already measured ON for `kit` (LIVE SURVEY 2026-09-23); `builds` is
|
|
89
|
-
* the gap.
|
|
89
|
+
* the gap. Fork-PR approval left this list on 2026-10-14: `ForkPrApproval` (fork-pr-approval.ts)
|
|
90
|
+
* declares it. */
|
|
90
91
|
export const PUBLIC_ONLY_UNDECLARABLE = [
|
|
91
92
|
'secret_scanning: enabled',
|
|
92
93
|
'secret_scanning_push_protection: enabled',
|
|
93
94
|
'private_vulnerability_reporting: enabled',
|
|
94
|
-
"fork-PR approval: 'first_time_contributors'",
|
|
95
95
|
] as const;
|
|
@@ -38,9 +38,14 @@
|
|
|
38
38
|
*/
|
|
39
39
|
import * as GitHub from 'alchemy/GitHub';
|
|
40
40
|
import * as Layer from 'effect/Layer';
|
|
41
|
+
import { ForkPrApprovalProvider } from './fork-pr-approval.ts';
|
|
41
42
|
import { RepositoryRulesetProvider } from './repository-ruleset.ts';
|
|
42
43
|
|
|
43
44
|
export const repoPolicyProviders = (options?: GitHub.ProvidersOptions) => {
|
|
44
45
|
const github = GitHub.providers(options);
|
|
45
|
-
return Layer.mergeAll(
|
|
46
|
+
return Layer.mergeAll(
|
|
47
|
+
github,
|
|
48
|
+
RepositoryRulesetProvider().pipe(Layer.provideMerge(github)),
|
|
49
|
+
ForkPrApprovalProvider().pipe(Layer.provideMerge(github)),
|
|
50
|
+
);
|
|
46
51
|
};
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
QuadletEnvironmentValueError,
|
|
16
16
|
environmentLines,
|
|
17
17
|
environmentValueProblems,
|
|
18
|
+
needsQuoting,
|
|
18
19
|
quoteEnvironmentValue,
|
|
19
20
|
} from './container-env-quote.ts';
|
|
20
21
|
import { containerProblems, renderContainerFile } from './container-form.ts';
|
|
@@ -62,13 +63,64 @@ describe('the rendered text', () => {
|
|
|
62
63
|
|
|
63
64
|
test('keys are sorted, so the render is stable for an unordered plain object', () => {
|
|
64
65
|
expect(environmentLines({ B: '2', A: '1' })).toEqual([
|
|
65
|
-
['Environment', '
|
|
66
|
-
['Environment', '
|
|
66
|
+
['Environment', 'A=1'],
|
|
67
|
+
['Environment', 'B=2'],
|
|
67
68
|
]);
|
|
68
69
|
expect(environmentLines({})).toEqual([]);
|
|
69
70
|
});
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
describe('quoted only when needed (0.53.1)', () => {
|
|
74
|
+
test('plain values render bare, byte-identical to the 0.51/0.52 renderer', () => {
|
|
75
|
+
expect(
|
|
76
|
+
environmentLines({
|
|
77
|
+
CADDY_CONFIG: '/etc/caddy/Caddyfile',
|
|
78
|
+
EMPTY: '',
|
|
79
|
+
URL: 'https://grafana.example/login?a=1&b=2#frag',
|
|
80
|
+
MIXED: 'a-b_c.d:e/f,g;h@i+j=k(l)[m]{n}*~!|<>?`',
|
|
81
|
+
}).map(([key, value]) => `${key}=${value}`),
|
|
82
|
+
).toEqual([
|
|
83
|
+
'Environment=CADDY_CONFIG=/etc/caddy/Caddyfile',
|
|
84
|
+
'Environment=EMPTY=',
|
|
85
|
+
'Environment=MIXED=a-b_c.d:e/f,g;h@i+j=k(l)[m]{n}*~!|<>?`',
|
|
86
|
+
'Environment=URL=https://grafana.example/login?a=1&b=2#frag',
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test.each([
|
|
91
|
+
['space', 'a b'],
|
|
92
|
+
['tab', 'a\tb'],
|
|
93
|
+
['double quote', 'say "hi"'],
|
|
94
|
+
['single quote', "it's"],
|
|
95
|
+
['percent', '50%off'],
|
|
96
|
+
['dollar', 'pa$word'],
|
|
97
|
+
['backslash', 'C:\\path'],
|
|
98
|
+
])('a value with a %s is quoted', (_name, value) => {
|
|
99
|
+
expect(needsQuoting(`K=${value}`)).toBe(true);
|
|
100
|
+
const rendered = quoteEnvironmentValue('K', value);
|
|
101
|
+
expect(rendered.startsWith('"K=')).toBe(true);
|
|
102
|
+
expect(rendered.endsWith('"')).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a single quote alone triggers the full quoted form', () => {
|
|
106
|
+
expect(quoteEnvironmentValue('K', "it's")).toBe(`"K=it's"`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a special character in the KEY also triggers quoting', () => {
|
|
110
|
+
expect(quoteEnvironmentValue('A%B', '1')).toBe('"A%%B=1"');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('plain values never need quoting', () => {
|
|
114
|
+
expect(needsQuoting('A=1')).toBe(false);
|
|
115
|
+
expect(needsQuoting('K=')).toBe(false);
|
|
116
|
+
expect(quoteEnvironmentValue('A', '1')).toBe('A=1');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('newlines are still refused before the bare/quoted decision', () => {
|
|
120
|
+
expect(() => quoteEnvironmentValue('K', 'plain\nplain')).toThrow(QuadletEnvironmentValueError);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
72
124
|
describe('newlines are refused, not encoded', () => {
|
|
73
125
|
test('a newline in a value is a typed error naming the key, never the value', () => {
|
|
74
126
|
expect(() => quoteEnvironmentValue('WEBHOOK_URL', 'https://x\nSECRET-TAIL')).toThrow(
|
|
@@ -65,6 +65,17 @@ export class QuadletEnvironmentValueError extends Data.TaggedError('QuadletEnvir
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The characters that make a bare `KEY=value` unsafe: whitespace (quadlet's splitter ends the
|
|
70
|
+
* assignment there), `"` and `'` (start/end a quoted region), `\\` (C-escape, which can fail the
|
|
71
|
+
* whole line), `%` (systemd specifier) and `$` (systemd variable expansion). Anything else passes
|
|
72
|
+
* quadlet, podman's re-emission and systemd's ExecStart parsing unchanged.
|
|
73
|
+
*/
|
|
74
|
+
const SPECIAL = /[\s"'\\%$]/u;
|
|
75
|
+
|
|
76
|
+
/** True when `assignment` must be wrapped and escaped to survive the three parsers. */
|
|
77
|
+
export const needsQuoting = (assignment: string): boolean => SPECIAL.test(assignment);
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
80
|
* `Environment="KEY=value"` — the whole assignment double-quoted, with `\`, `"`, `%` and `$`
|
|
70
81
|
* escaped (in that order, over the joined `KEY=value`), so the wrapping quotes this function adds
|
|
@@ -76,6 +87,10 @@ export class QuadletEnvironmentValueError extends Data.TaggedError('QuadletEnvir
|
|
|
76
87
|
*/
|
|
77
88
|
export const quoteEnvironmentValue = (key: string, value: string): string => {
|
|
78
89
|
if (key.includes('\n') || value.includes('\n')) throw new QuadletEnvironmentValueError({ key });
|
|
90
|
+
// ★ QUOTE ONLY WHEN NEEDED (0.53.1): a `KEY=value` with none of the characters any of the three
|
|
91
|
+
// parsers treats specially passes all of them verbatim, so it renders bare — byte-identical to
|
|
92
|
+
// the 0.51/0.52 renderer, so units holding only plain values do not change (and do not restart).
|
|
93
|
+
if (!needsQuoting(`${key}=${value}`)) return `${key}=${value}`;
|
|
79
94
|
// ⛔ THE `$` REPLACEMENT MUST BE A FUNCTION — measured 2026-10-12: `replaceAll`'s replacement
|
|
80
95
|
// STRING runs its own `$`-pattern mini-language (same fold as systemd: `$$` in it IS one
|
|
81
96
|
// literal `$`, and `$&` is the match), so a plain `'$$'` replacement silently no-ops and the
|
|
@@ -66,9 +66,9 @@ test('renders every directive CT100’s live caddy.container carries', () => {
|
|
|
66
66
|
'ContainerName=caddy',
|
|
67
67
|
'Network=host',
|
|
68
68
|
'EnvironmentFile=/etc/caddy/cf-token.env',
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
'Environment=
|
|
69
|
+
// ★ Bare again since 0.53.1: the value holds nothing any parser treats specially, so it renders
|
|
70
|
+
// exactly as the live file (and 0.51/0.52) has it — container-env-quote.ts quotes only when needed.
|
|
71
|
+
'Environment=CADDY_CONFIG=/etc/caddy/Caddyfile',
|
|
72
72
|
'Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
|
|
73
73
|
'Volume=/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
|
|
74
74
|
'Volume=/etc/caddy/cf-token.env:/run/cf/token.env:ro',
|