@davidvornholt/standards 0.26.3 → 0.27.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/README.md +36 -1
- package/package.json +7 -2
- package/src/cli.ts +154 -17
- package/src/creds-add-github.ts +12 -4
- package/src/creds-add.ts +14 -1
- package/src/creds-commands.ts +7 -1
- package/src/creds-github-apps.ts +71 -2
- package/src/creds-login-cloudflare.ts +6 -6
- package/src/creds-login-github.ts +6 -2
- package/src/creds-naming.ts +4 -1
- package/src/creds-plan-renew.ts +14 -2
- package/src/creds-plan-report.ts +1 -1
- package/src/creds-plan-unmanaged.ts +4 -5
- package/src/creds-plan.ts +1 -1
- package/src/creds-revoke-duplicate.ts +127 -0
- package/src/creds-revoke.ts +36 -15
- package/src/creds-store-lock-inspection.ts +26 -21
- package/src/creds-store-lock.ts +14 -1
- package/src/dependabot-directory-overlap.ts +100 -8
- package/src/dependabot-update.ts +11 -0
- package/src/dev-env-brokered-allowlist.ts +2 -2
- package/src/dev-env-brokered-preflight.ts +69 -0
- package/src/dev-env-brokered-preserve.ts +2 -1
- package/src/dev-env-brokered-refresh.ts +1 -1
- package/src/dev-env-brokered-resolve.ts +33 -22
- package/src/dev-env-brokered-source.ts +128 -0
- package/src/dev-env-brokered.ts +15 -2
- package/src/dev-env.ts +22 -0
- package/src/github-api.ts +5 -2
- package/src/github-apply.ts +26 -2
- package/src/github-bypass-actors.ts +2 -2
- package/src/sops-action-callers.ts +133 -0
- package/src/source-text.ts +98 -0
- package/src/structure-check.ts +5 -1
- package/src/structure-profile.ts +2 -0
package/src/creds-naming.ts
CHANGED
|
@@ -60,7 +60,10 @@ export const parseTokenName = (
|
|
|
60
60
|
repo: string,
|
|
61
61
|
): BrokeredTokenRef | null => {
|
|
62
62
|
const prefix = repoTokenPrefix(repo);
|
|
63
|
-
if (
|
|
63
|
+
if (
|
|
64
|
+
!isInMintedNamespace(name) ||
|
|
65
|
+
name.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()
|
|
66
|
+
) {
|
|
64
67
|
return null;
|
|
65
68
|
}
|
|
66
69
|
const rest = name.slice(prefix.length);
|
package/src/creds-plan-renew.ts
CHANGED
|
@@ -8,7 +8,8 @@ import type { PlannedAction } from './creds-plan-types';
|
|
|
8
8
|
import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
|
|
9
9
|
import { inspectSopsScalarDestination, setSopsValues } from './creds-sops';
|
|
10
10
|
import { verifySopsStoredValue } from './creds-sops-value';
|
|
11
|
-
import type
|
|
11
|
+
import { type CloudflareBrokerAccount, resolveBrokerPath } from './creds-store';
|
|
12
|
+
import { withBrokerLock } from './creds-store-lock';
|
|
12
13
|
import { resolveTargetRelResult } from './creds-target';
|
|
13
14
|
import type { BrokeredRefreshEvidence } from './dev-env-brokered-refresh';
|
|
14
15
|
|
|
@@ -63,7 +64,7 @@ const inspectRenewDestinations = async (
|
|
|
63
64
|
: null;
|
|
64
65
|
};
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
const renewWhileLocked = async (
|
|
67
68
|
consumer: string,
|
|
68
69
|
account: CloudflareBrokerAccount,
|
|
69
70
|
action: RenewAction,
|
|
@@ -151,3 +152,14 @@ export const renewPlannedToken = async (
|
|
|
151
152
|
refreshEvidence(action, 'verified'),
|
|
152
153
|
);
|
|
153
154
|
};
|
|
155
|
+
|
|
156
|
+
// Duplicate retirement uses this same lock. Hold it before minting until the
|
|
157
|
+
// replacement is durably verified and the old token's deletion has finished.
|
|
158
|
+
export const renewPlannedToken = (
|
|
159
|
+
consumer: string,
|
|
160
|
+
account: CloudflareBrokerAccount,
|
|
161
|
+
action: RenewAction,
|
|
162
|
+
): Promise<RenewPlannedTokenResult> =>
|
|
163
|
+
withBrokerLock(resolveBrokerPath(), () =>
|
|
164
|
+
renewWhileLocked(consumer, account, action),
|
|
165
|
+
);
|
package/src/creds-plan-report.ts
CHANGED
|
@@ -25,7 +25,7 @@ const revokeCommand = (token: UnmanagedToken, extra: string): string =>
|
|
|
25
25
|
// send the operator down a dead end. The remedy is the dashboard, which is
|
|
26
26
|
// also the only place the machine it belongs to can be confirmed.
|
|
27
27
|
const unmanagedRemedy = (token: UnmanagedToken): string =>
|
|
28
|
-
token.name === BROKER_IDENTITY_NAME
|
|
28
|
+
token.name.toLowerCase() === BROKER_IDENTITY_NAME
|
|
29
29
|
? "the reserved name for a machine's broker bootstrap credential, which `revoke` refuses; retire it in the Cloudflare dashboard, where you can confirm which machine it belongs to"
|
|
30
30
|
: `retire it with ${revokeCommand(token, '')}`;
|
|
31
31
|
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import type { CloudflareToken } from './creds-cloudflare-api';
|
|
9
9
|
import {
|
|
10
10
|
type BrokeredTokenRef,
|
|
11
|
-
isInMintedNamespace,
|
|
12
11
|
parseAnyTokenName,
|
|
13
12
|
parseTokenName,
|
|
14
13
|
repoTokenPrefix,
|
|
@@ -39,7 +38,9 @@ const classifyAccountToken = (
|
|
|
39
38
|
if (token.status === 'expired') {
|
|
40
39
|
return { kind: 'ignored' };
|
|
41
40
|
}
|
|
42
|
-
if (
|
|
41
|
+
if (
|
|
42
|
+
token.name.toLowerCase().startsWith(repoTokenPrefix(repo).toLowerCase())
|
|
43
|
+
) {
|
|
43
44
|
return { kind: 'malformed' };
|
|
44
45
|
}
|
|
45
46
|
// A brokered name carries the repository that owns it, which normally
|
|
@@ -53,9 +54,7 @@ const classifyAccountToken = (
|
|
|
53
54
|
if (elsewhere !== null) {
|
|
54
55
|
return { kind: 'brokered-elsewhere', repo: elsewhere.repo };
|
|
55
56
|
}
|
|
56
|
-
return
|
|
57
|
-
? { kind: 'ignored' }
|
|
58
|
-
: { kind: 'unmanaged' };
|
|
57
|
+
return { kind: 'unmanaged' };
|
|
59
58
|
};
|
|
60
59
|
|
|
61
60
|
export type ManagedTokenRef = AccountToken & {
|
package/src/creds-plan.ts
CHANGED
|
@@ -133,7 +133,7 @@ const dispositionForGroup = (
|
|
|
133
133
|
const [first] = group;
|
|
134
134
|
return {
|
|
135
135
|
kind: 'finding',
|
|
136
|
-
finding: `ambiguous Cloudflare tokens have destination footprints intersecting in ${first?.ref.target}: ${group.map((candidate) => `${candidate.ref.key} (${candidate.accountId}/${candidate.token.id})`).join(', ')};
|
|
136
|
+
finding: `ambiguous Cloudflare tokens have destination footprints intersecting in ${first?.ref.target}: ${group.map((candidate) => `${candidate.ref.key} (${candidate.accountId}/${candidate.token.id})`).join(', ')}; for same-name duplicates, keep the encrypted key intact and use \`standards creds revoke --account <account-id> --token-id <duplicate-id>\` to retire only a token proven not to be stored; resolve other intersecting destinations before plan/apply`,
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
const [entry] = group;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isContainedPath } from './contained-path';
|
|
4
|
+
import { deleteAccountToken, verifyAccountToken } from './creds-cloudflare';
|
|
5
|
+
import type { CloudflareToken } from './creds-cloudflare-api';
|
|
6
|
+
import type { BrokeredTokenRef } from './creds-naming';
|
|
7
|
+
import { lookupS3Pair, s3PairPaths } from './creds-r2';
|
|
8
|
+
import { verifySopsStoredValue } from './creds-sops-value';
|
|
9
|
+
import type { CloudflareBrokerAccount } from './creds-store';
|
|
10
|
+
import { resolveBrokerPath } from './creds-store';
|
|
11
|
+
import { withBrokerLock } from './creds-store-lock';
|
|
12
|
+
import { resolveTargetRelResult } from './creds-target';
|
|
13
|
+
import { isRecord } from './github-settings-parse';
|
|
14
|
+
import { decryptSopsJson } from './sops-exec';
|
|
15
|
+
|
|
16
|
+
type Proof =
|
|
17
|
+
| {
|
|
18
|
+
readonly ok: true;
|
|
19
|
+
readonly id: string | null;
|
|
20
|
+
readonly values: ReadonlyArray<{
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly value: string;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
| { readonly ok: false };
|
|
26
|
+
const storedProof = async (
|
|
27
|
+
document: unknown,
|
|
28
|
+
key: string,
|
|
29
|
+
accountId: string,
|
|
30
|
+
): Promise<Proof> => {
|
|
31
|
+
let node = document;
|
|
32
|
+
for (const segment of key.split('.')) {
|
|
33
|
+
if (!isRecord(node)) {
|
|
34
|
+
return { ok: false };
|
|
35
|
+
}
|
|
36
|
+
if (!Object.hasOwn(node, segment)) {
|
|
37
|
+
return { ok: true, id: null, values: [] };
|
|
38
|
+
}
|
|
39
|
+
node = node[segment];
|
|
40
|
+
}
|
|
41
|
+
if (typeof node === 'string') {
|
|
42
|
+
const verified = await verifyAccountToken(accountId, node);
|
|
43
|
+
return verified.ok && verified.value.status === 'active'
|
|
44
|
+
? {
|
|
45
|
+
ok: true,
|
|
46
|
+
id: verified.value.id,
|
|
47
|
+
values: [{ path: key, value: node }],
|
|
48
|
+
}
|
|
49
|
+
: { ok: false };
|
|
50
|
+
}
|
|
51
|
+
const pair = lookupS3Pair(document, key);
|
|
52
|
+
if (!pair.ok) {
|
|
53
|
+
return { ok: false };
|
|
54
|
+
}
|
|
55
|
+
const [accessPath, secretPath] = s3PairPaths(key);
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
id: pair.accessKeyId,
|
|
59
|
+
values: [
|
|
60
|
+
{ path: accessPath, value: pair.accessKeyId },
|
|
61
|
+
{ path: secretPath, value: pair.secretAccessKey },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const unchangedStoredValues = (
|
|
67
|
+
consumer: string,
|
|
68
|
+
rel: string,
|
|
69
|
+
proof: Extract<Proof, { readonly ok: true }>,
|
|
70
|
+
ciphertext: Buffer,
|
|
71
|
+
): boolean =>
|
|
72
|
+
proof.values.every(({ path, value }) => {
|
|
73
|
+
const result = verifySopsStoredValue(consumer, rel, path, value);
|
|
74
|
+
return result.ok && result.matches;
|
|
75
|
+
}) && readFileSync(join(consumer, rel)).equals(ciphertext);
|
|
76
|
+
|
|
77
|
+
const isActiveDuplicate = (
|
|
78
|
+
id: string | null,
|
|
79
|
+
duplicates: ReadonlyArray<CloudflareToken>,
|
|
80
|
+
): boolean =>
|
|
81
|
+
id === null ||
|
|
82
|
+
duplicates.some((token) => token.id === id && token.status === 'active');
|
|
83
|
+
|
|
84
|
+
// Only the explicit non-stored member of a same-name duplicate set may be
|
|
85
|
+
// retired. Keep the current secret and every token if identity is uncertain.
|
|
86
|
+
export const revokeStoredDuplicate = async (input: {
|
|
87
|
+
readonly consumer: string;
|
|
88
|
+
readonly account: CloudflareBrokerAccount;
|
|
89
|
+
readonly tokenId: string;
|
|
90
|
+
readonly ref: BrokeredTokenRef;
|
|
91
|
+
readonly duplicates: ReadonlyArray<CloudflareToken>;
|
|
92
|
+
}): Promise<string | null> =>
|
|
93
|
+
withBrokerLock(resolveBrokerPath(), async () => {
|
|
94
|
+
const { consumer, account, ref, tokenId, duplicates } = input;
|
|
95
|
+
const target = resolveTargetRelResult(consumer, ref.target);
|
|
96
|
+
if (!(target.ok && isContainedPath(consumer, target.rel, 'file'))) {
|
|
97
|
+
return 'cannot safely resolve the duplicate token SOPS destination; keep its stored key intact';
|
|
98
|
+
}
|
|
99
|
+
const ciphertext = readFileSync(join(consumer, target.rel));
|
|
100
|
+
const decrypted = decryptSopsJson(consumer, target.rel);
|
|
101
|
+
if (!decrypted.ok) {
|
|
102
|
+
return 'cannot decrypt the duplicate token destination; keep its stored key intact';
|
|
103
|
+
}
|
|
104
|
+
const proof = await storedProof(
|
|
105
|
+
decrypted.value,
|
|
106
|
+
ref.key,
|
|
107
|
+
account.accountId,
|
|
108
|
+
);
|
|
109
|
+
if (!proof.ok) {
|
|
110
|
+
return 'cannot prove which duplicate token the stored credential uses; keep its stored key intact';
|
|
111
|
+
}
|
|
112
|
+
if (!isActiveDuplicate(proof.id, duplicates)) {
|
|
113
|
+
return 'stored credential does not identify an active duplicate; no token revoked';
|
|
114
|
+
}
|
|
115
|
+
if (proof.id === tokenId) {
|
|
116
|
+
return `token ${tokenId} is the credential currently stored at ${ref.target}:${ref.key}; choose a different duplicate ID`;
|
|
117
|
+
}
|
|
118
|
+
if (!unchangedStoredValues(consumer, target.rel, proof, ciphertext)) {
|
|
119
|
+
return 'stored credential changed or became unreadable during duplicate verification; no token revoked';
|
|
120
|
+
}
|
|
121
|
+
const deleted = await deleteAccountToken(
|
|
122
|
+
account.accountId,
|
|
123
|
+
account.token,
|
|
124
|
+
tokenId,
|
|
125
|
+
);
|
|
126
|
+
return deleted.ok ? null : deleted.problem;
|
|
127
|
+
});
|
package/src/creds-revoke.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// one and deleting the original; without this the second half could only be
|
|
4
4
|
// done in the dashboard. The power granted is deliberately momentary: nothing
|
|
5
5
|
// is recorded, nothing becomes automatic, and a human names the target every
|
|
6
|
-
// time.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// time. Singular tokens reconciliation owns are retired through apply. A
|
|
7
|
+
// same-name duplicate may be explicitly retired after proving it is not the
|
|
8
|
+
// stored credential; this recovers interrupted renewal without deleting a key.
|
|
9
9
|
|
|
10
10
|
import { deleteAccountToken } from './creds-cloudflare';
|
|
11
11
|
import { isCloudflareId } from './creds-cloudflare-id';
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type BrokeredTokenRef,
|
|
17
17
|
parseAnyTokenName,
|
|
18
18
|
} from './creds-naming';
|
|
19
|
+
import { revokeStoredDuplicate } from './creds-revoke-duplicate';
|
|
19
20
|
import { readBrokerStore, resolveBrokerPath } from './creds-store';
|
|
20
21
|
import { resolveGithubRepo } from './github-api';
|
|
21
22
|
|
|
@@ -30,8 +31,8 @@ const fail = (message: string): false => {
|
|
|
30
31
|
// live credential this very checkout reconciles. Owner and name are compared
|
|
31
32
|
// together: `otherowner/example` shares this repository's name and nothing
|
|
32
33
|
// else.
|
|
33
|
-
const sameRepo = (left: string, right: string): boolean =>
|
|
34
|
-
left.toLowerCase() === right.toLowerCase();
|
|
34
|
+
const sameRepo = (left: string, right: string | null): boolean =>
|
|
35
|
+
right !== null && left.toLowerCase() === right.toLowerCase();
|
|
35
36
|
|
|
36
37
|
// A brokered name carries the repository that owns it, and the reconciled
|
|
37
38
|
// remedy — delete the SOPS key, run `apply` — only works in that repository.
|
|
@@ -57,15 +58,8 @@ const brokeredRefusal = ({
|
|
|
57
58
|
if (currentRepo === null) {
|
|
58
59
|
return `token ${tokenId} is brokered as ${name}, which belongs to ${brokered.repo}, and this checkout cannot resolve the GitHub repository from its origin remote, so whether this checkout owns the token could not be determined; --force widens only that ownership check, so here it has nothing to widen. Re-run from a checkout whose origin remote names a GitHub repository, or point --dir at one. If ${brokered.repo} still exists, the remedy runs there: delete ${brokered.target}:${brokered.key} from its SOPS target and run \`standards creds apply\` in that checkout`;
|
|
59
60
|
}
|
|
60
|
-
if (brokered.repo === currentRepo) {
|
|
61
|
-
return `token ${tokenId} is brokered as ${name} for this repository (${brokered.repo}); delete ${brokered.target}:${brokered.key} from this repository's SOPS target and run \`standards creds apply\`, which revokes it and keeps the secret in step`;
|
|
62
|
-
}
|
|
63
|
-
// Revoke compares repository names case-insensitively, but reconciliation
|
|
64
|
-
// matches the token name exactly, so a checkout whose origin differs only in
|
|
65
|
-
// capitalisation renews nothing and revokes nothing. Sending the operator to
|
|
66
|
-
// the reconciled remedy would prescribe a no-op.
|
|
67
61
|
if (sameRepo(brokered.repo, currentRepo)) {
|
|
68
|
-
return `token ${tokenId} is brokered as ${name}
|
|
62
|
+
return `token ${tokenId} is brokered as ${name} for this repository (${brokered.repo}); delete ${brokered.target}:${brokered.key} from this repository's SOPS target and run \`standards creds apply\`, which revokes it and keeps the secret in step`;
|
|
69
63
|
}
|
|
70
64
|
if (force) {
|
|
71
65
|
return null;
|
|
@@ -73,6 +67,16 @@ const brokeredRefusal = ({
|
|
|
73
67
|
return `token ${tokenId} is brokered as ${name}, which belongs to ${brokered.repo}, and this checkout is ${currentRepo}; the remedy runs there: delete ${brokered.target}:${brokered.key} from ${brokered.repo}'s SOPS target and run \`standards creds apply\` in that checkout. Pass --force only if ${brokered.repo} was renamed, transferred, or deleted, so no checkout resolves to that name and no apply will ever revoke this token`;
|
|
74
68
|
};
|
|
75
69
|
|
|
70
|
+
const reportDuplicate = (problem: string | null, label: string): boolean => {
|
|
71
|
+
if (problem !== null) {
|
|
72
|
+
return fail(problem);
|
|
73
|
+
}
|
|
74
|
+
console.log(
|
|
75
|
+
`standards creds: revoked duplicate ${label}; preserved the stored credential`,
|
|
76
|
+
);
|
|
77
|
+
return true;
|
|
78
|
+
};
|
|
79
|
+
|
|
76
80
|
export const runCredsRevoke = async (options: {
|
|
77
81
|
readonly account: string | undefined;
|
|
78
82
|
readonly tokenId: string | undefined;
|
|
@@ -121,18 +125,35 @@ export const runCredsRevoke = async (options: {
|
|
|
121
125
|
// — one per machine. Only this machine's is identified by ID above; the rest
|
|
122
126
|
// are refused by name, because deleting one locks another machine out of the
|
|
123
127
|
// account and the broker cannot re-mint its own root credential.
|
|
124
|
-
if (target.name === BROKER_IDENTITY_NAME) {
|
|
128
|
+
if (target.name.toLowerCase() === BROKER_IDENTITY_NAME) {
|
|
125
129
|
return fail(
|
|
126
130
|
`token ${tokenId} is named ${BROKER_IDENTITY_NAME}, the reserved name for a machine's broker bootstrap credential; this machine's own bootstrap is a different token, so this one belongs to another machine or is a superseded one — retire it in the Cloudflare dashboard, where you can confirm which machine it belongs to`,
|
|
127
131
|
);
|
|
128
132
|
}
|
|
129
133
|
const brokered = parseAnyTokenName(target.name);
|
|
130
134
|
if (brokered !== null) {
|
|
135
|
+
const currentRepo = resolveGithubRepo(options.dir);
|
|
136
|
+
const duplicates = identified.value.tokens.filter(
|
|
137
|
+
(entry) => entry.name === target.name,
|
|
138
|
+
);
|
|
139
|
+
if (sameRepo(brokered.repo, currentRepo) && duplicates.length > 1) {
|
|
140
|
+
const problem = await revokeStoredDuplicate({
|
|
141
|
+
consumer: options.dir,
|
|
142
|
+
account,
|
|
143
|
+
tokenId,
|
|
144
|
+
ref: brokered,
|
|
145
|
+
duplicates,
|
|
146
|
+
});
|
|
147
|
+
return reportDuplicate(
|
|
148
|
+
problem,
|
|
149
|
+
`${target.name} (${account.accountId}/${tokenId})`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
131
152
|
const refusal = brokeredRefusal({
|
|
132
153
|
tokenId,
|
|
133
154
|
name: target.name,
|
|
134
155
|
brokered,
|
|
135
|
-
currentRepo
|
|
156
|
+
currentRepo,
|
|
136
157
|
force: options.force,
|
|
137
158
|
});
|
|
138
159
|
if (refusal !== null) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, readFile, rmdir,
|
|
1
|
+
import { lstat, readdir, readFile, rmdir, unlink } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isRecord } from './github-settings-parse';
|
|
4
4
|
|
|
@@ -24,15 +24,11 @@ type ExistingGeneration =
|
|
|
24
24
|
readonly modifiedAt: number;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
};
|
|
27
|
+
const missingOr = (
|
|
28
|
+
error: unknown,
|
|
29
|
+
fallback: ExistingGeneration,
|
|
30
|
+
): ExistingGeneration =>
|
|
31
|
+
isErrorCode(error, 'ENOENT') ? { kind: 'missing' } : fallback;
|
|
36
32
|
|
|
37
33
|
const readExistingGeneration = async (
|
|
38
34
|
lockPath: string,
|
|
@@ -41,15 +37,12 @@ const readExistingGeneration = async (
|
|
|
41
37
|
try {
|
|
42
38
|
names = await readdir(lockPath);
|
|
43
39
|
} catch (error) {
|
|
44
|
-
return
|
|
45
|
-
? { kind: 'missing' }
|
|
46
|
-
: { kind: 'unknown' };
|
|
40
|
+
return missingOr(error, { kind: 'unknown' });
|
|
47
41
|
}
|
|
48
42
|
if (names.length === 0) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
: { entryPath: null, kind: 'incomplete', modifiedAt };
|
|
43
|
+
// Empty directories are immediately replaceable by an initialized
|
|
44
|
+
// candidate's atomic rename, including the holder-unlink release gap.
|
|
45
|
+
return { kind: 'missing' };
|
|
53
46
|
}
|
|
54
47
|
const entryName = names.length === 1 ? names[0] : undefined;
|
|
55
48
|
if (entryName === undefined) {
|
|
@@ -58,9 +51,13 @@ const readExistingGeneration = async (
|
|
|
58
51
|
const entryPath = join(lockPath, entryName);
|
|
59
52
|
let modifiedAt: number;
|
|
60
53
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
const entry = await lstat(entryPath);
|
|
55
|
+
if (!entry.isFile()) {
|
|
56
|
+
return { kind: 'unknown' };
|
|
57
|
+
}
|
|
58
|
+
modifiedAt = entry.mtimeMs;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return missingOr(error, { kind: 'unknown' });
|
|
64
61
|
}
|
|
65
62
|
const incomplete = {
|
|
66
63
|
entryPath,
|
|
@@ -72,8 +69,16 @@ const readExistingGeneration = async (
|
|
|
72
69
|
) {
|
|
73
70
|
return incomplete;
|
|
74
71
|
}
|
|
72
|
+
let raw: string;
|
|
73
|
+
try {
|
|
74
|
+
raw = await readFile(entryPath, 'utf8');
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// Only a vanished regular holder permits immediate retry. An I/O error
|
|
77
|
+
// cannot establish that a stale holder is malformed or safe to reclaim.
|
|
78
|
+
return missingOr(error, { kind: 'unknown' });
|
|
79
|
+
}
|
|
75
80
|
try {
|
|
76
|
-
const decoded: unknown = JSON.parse(
|
|
81
|
+
const decoded: unknown = JSON.parse(raw);
|
|
77
82
|
const generation = entryName.slice(
|
|
78
83
|
HOLDER_PREFIX.length,
|
|
79
84
|
-HOLDER_SUFFIX.length,
|
package/src/creds-store-lock.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { mkdir } from 'node:fs/promises';
|
|
9
9
|
import { dirname } from 'node:path';
|
|
10
|
+
import process from 'node:process';
|
|
10
11
|
import {
|
|
11
12
|
type HeldBrokerLock,
|
|
12
13
|
releaseBrokerLock,
|
|
@@ -74,6 +75,18 @@ export const withBrokerLock = async <T>(
|
|
|
74
75
|
try {
|
|
75
76
|
return await operation();
|
|
76
77
|
} finally {
|
|
77
|
-
|
|
78
|
+
try {
|
|
79
|
+
await releaseBrokerLock(held);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Cleanup must preserve either the durable operation result or its
|
|
82
|
+
// original failure. The stopped lease makes abandoned holders stale.
|
|
83
|
+
try {
|
|
84
|
+
process.stderr.write(
|
|
85
|
+
`standards creds: warning: lock cleanup failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}; the operation result is unchanged; retry after the stale-lock window\n`,
|
|
86
|
+
);
|
|
87
|
+
} catch {
|
|
88
|
+
// Even an unavailable diagnostic stream cannot change that result.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
78
91
|
}
|
|
79
92
|
};
|
|
@@ -15,7 +15,7 @@ type Automaton = {
|
|
|
15
15
|
readonly edges: ReadonlyArray<Edge>;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const compilePattern = (
|
|
19
19
|
directory: string,
|
|
20
20
|
supportsGlobs: boolean,
|
|
21
21
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ruby glob tokens have distinct NFA transitions that are clearest in one ordered parser.
|
|
@@ -94,15 +94,11 @@ const compileDirectory = (
|
|
|
94
94
|
return { accept: current, edges };
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
rightDirectory: string,
|
|
101
|
-
rightSupportsGlobs: boolean,
|
|
97
|
+
const automataOverlap = (
|
|
98
|
+
left: Automaton,
|
|
99
|
+
right: Automaton,
|
|
102
100
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The product walk must process both automata's epsilon and consuming transitions together.
|
|
103
101
|
): boolean => {
|
|
104
|
-
const left = compileDirectory(leftDirectory, leftSupportsGlobs);
|
|
105
|
-
const right = compileDirectory(rightDirectory, rightSupportsGlobs);
|
|
106
102
|
const pending: Array<readonly [number, number]> = [[0, 0]];
|
|
107
103
|
const visited = new Set<string>();
|
|
108
104
|
for (const [leftState, rightState] of pending) {
|
|
@@ -137,3 +133,99 @@ export const directoriesOverlap = (
|
|
|
137
133
|
}
|
|
138
134
|
return false;
|
|
139
135
|
};
|
|
136
|
+
|
|
137
|
+
const dotAliasScan = (segments: ReadonlyArray<string>) => {
|
|
138
|
+
const firstScan = segments.findIndex((segment) =>
|
|
139
|
+
compilePattern(segment, true).edges.some(
|
|
140
|
+
(edge) => edge.matcher === null || edge.matcher.kind !== 'literal',
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
let aliasIndex = firstScan;
|
|
144
|
+
while (segments[aliasIndex] === '**' && aliasIndex < segments.length - 1) {
|
|
145
|
+
aliasIndex += 1;
|
|
146
|
+
}
|
|
147
|
+
return { firstScan, aliasIndex };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const compileDirectory = (
|
|
151
|
+
directory: string,
|
|
152
|
+
supportsGlobs: boolean,
|
|
153
|
+
): Automaton => {
|
|
154
|
+
if (!supportsGlobs || directory === '/') {
|
|
155
|
+
return compilePattern(directory, supportsGlobs);
|
|
156
|
+
}
|
|
157
|
+
const edges: Array<Edge> = [];
|
|
158
|
+
let current = 0;
|
|
159
|
+
let states = 1;
|
|
160
|
+
const segments = directory.split('/').slice(1);
|
|
161
|
+
const { firstScan, aliasIndex } = dotAliasScan(segments);
|
|
162
|
+
let aliasStart = 0;
|
|
163
|
+
for (const [index, segment] of segments.entries()) {
|
|
164
|
+
if (index === firstScan) {
|
|
165
|
+
aliasStart = current;
|
|
166
|
+
}
|
|
167
|
+
if (segment === '**' && index < segments.length - 1) {
|
|
168
|
+
const loop = states;
|
|
169
|
+
states += 1;
|
|
170
|
+
edges.push({ from: current, to: loop, matcher: null });
|
|
171
|
+
current = loop;
|
|
172
|
+
const part = compilePattern('/*', true);
|
|
173
|
+
const offset = states;
|
|
174
|
+
states += part.accept + 1;
|
|
175
|
+
edges.push(
|
|
176
|
+
{ from: current, to: offset, matcher: null },
|
|
177
|
+
...part.edges.map((edge) => ({
|
|
178
|
+
...edge,
|
|
179
|
+
from: edge.from + offset,
|
|
180
|
+
to: edge.to + offset,
|
|
181
|
+
})),
|
|
182
|
+
{ from: offset + part.accept, to: current, matcher: null },
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
const part = compilePattern(`/${segment}`, true);
|
|
186
|
+
const offset = states;
|
|
187
|
+
states += part.accept + 1;
|
|
188
|
+
edges.push(
|
|
189
|
+
{ from: current, to: offset, matcher: null },
|
|
190
|
+
...part.edges.map((edge) => ({
|
|
191
|
+
...edge,
|
|
192
|
+
from: edge.from + offset,
|
|
193
|
+
to: edge.to + offset,
|
|
194
|
+
})),
|
|
195
|
+
);
|
|
196
|
+
current = offset + part.accept;
|
|
197
|
+
if (
|
|
198
|
+
index === aliasIndex &&
|
|
199
|
+
automataOverlap(
|
|
200
|
+
compilePattern(segment, true),
|
|
201
|
+
compilePattern('.', false),
|
|
202
|
+
)
|
|
203
|
+
) {
|
|
204
|
+
// Ruby sets FNM_GLOB_SKIPDOT after its first directory scan. A
|
|
205
|
+
// recursive prefix may match zero directories in that same scan;
|
|
206
|
+
// after consuming a child it cannot return to this alias start.
|
|
207
|
+
edges.push({ from: aliasStart, to: current, matcher: null });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const compiled = { accept: current, edges };
|
|
212
|
+
if (automataOverlap(compiled, { accept: 0, edges: [] })) {
|
|
213
|
+
edges.push({
|
|
214
|
+
from: 0,
|
|
215
|
+
to: current,
|
|
216
|
+
matcher: { kind: 'literal', value: '/'.codePointAt(0) ?? 0 },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return compiled;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const directoriesOverlap = (
|
|
223
|
+
leftDirectory: string,
|
|
224
|
+
leftSupportsGlobs: boolean,
|
|
225
|
+
rightDirectory: string,
|
|
226
|
+
rightSupportsGlobs: boolean,
|
|
227
|
+
): boolean =>
|
|
228
|
+
automataOverlap(
|
|
229
|
+
compileDirectory(leftDirectory, leftSupportsGlobs),
|
|
230
|
+
compileDirectory(rightDirectory, rightSupportsGlobs),
|
|
231
|
+
);
|
package/src/dependabot-update.ts
CHANGED
|
@@ -110,6 +110,17 @@ export const updateTarget = (
|
|
|
110
110
|
const rawDirectories: ReadonlyArray<string> = hasDirectory
|
|
111
111
|
? [directory as string]
|
|
112
112
|
: (directories as ReadonlyArray<string>);
|
|
113
|
+
if (
|
|
114
|
+
hasDirectories &&
|
|
115
|
+
rawDirectories.some(
|
|
116
|
+
(path) =>
|
|
117
|
+
isDependabotGlob(path) && (path.includes('{') || path.includes('}')),
|
|
118
|
+
)
|
|
119
|
+
) {
|
|
120
|
+
problems.push(
|
|
121
|
+
`${label}.directories brace expansion is unsupported; list each alternative explicitly`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
113
124
|
const normalized = [...new Set(rawDirectories)].sort();
|
|
114
125
|
const normalizedDirectories = [
|
|
115
126
|
...new Set(normalized.map(normalizeDependabotDirectory)),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { validBrokeredIdentity } from './dev-env-brokered-source';
|
|
2
2
|
import type { DevEnvLayerKind } from './dev-env-document';
|
|
3
3
|
|
|
4
4
|
export const BROKERED_REFERENCES_KEY = 'brokeredReferences';
|
|
@@ -32,7 +32,7 @@ export const parseBrokeredAllowlist = (
|
|
|
32
32
|
const entries = new Set<string>();
|
|
33
33
|
const problems: Array<string> = [];
|
|
34
34
|
for (const [index, entry] of raw.entries()) {
|
|
35
|
-
if (typeof entry !== 'string' ||
|
|
35
|
+
if (typeof entry !== 'string' || !validBrokeredIdentity(entry)) {
|
|
36
36
|
problems.push(
|
|
37
37
|
`${source} "${BROKERED_REFERENCES_KEY}" entry ${index + 1} must be an exact "<target>:<dotted.key>" string`,
|
|
38
38
|
);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { BrokeredS3Reference } from './dev-env-brokered';
|
|
2
|
+
import {
|
|
3
|
+
brokeredReferenceIdentity,
|
|
4
|
+
resolveBrokeredSource,
|
|
5
|
+
} from './dev-env-brokered-source';
|
|
6
|
+
import type { ComposedDevEnvTarget } from './dev-env-compose';
|
|
7
|
+
|
|
8
|
+
type SelectedReference = Readonly<{
|
|
9
|
+
label: string;
|
|
10
|
+
reference: BrokeredS3Reference;
|
|
11
|
+
}>;
|
|
12
|
+
type Source = Readonly<{ root: string; rel: string }>;
|
|
13
|
+
const selectedReferences = (
|
|
14
|
+
targets: ReadonlyArray<ComposedDevEnvTarget>,
|
|
15
|
+
): ReadonlyArray<SelectedReference> =>
|
|
16
|
+
targets.flatMap((target) =>
|
|
17
|
+
Object.entries(target.env).flatMap(([key, reference]) =>
|
|
18
|
+
typeof reference === 'string'
|
|
19
|
+
? []
|
|
20
|
+
: [
|
|
21
|
+
{
|
|
22
|
+
label: `${target.group}.${target.workspace}.${key} reference to secrets target "${reference.brokeredS3}"`,
|
|
23
|
+
reference,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export const prepareBrokeredSources = (
|
|
30
|
+
consumer: string,
|
|
31
|
+
targets: ReadonlyArray<ComposedDevEnvTarget>,
|
|
32
|
+
allowed: ReadonlySet<string>,
|
|
33
|
+
preserved: ReadonlySet<string>,
|
|
34
|
+
): Readonly<{
|
|
35
|
+
sources: ReadonlyMap<BrokeredS3Reference, Source>;
|
|
36
|
+
problems: ReadonlyArray<string>;
|
|
37
|
+
}> => {
|
|
38
|
+
const sources = new Map<BrokeredS3Reference, Source>();
|
|
39
|
+
const roots = new Map<string, string>();
|
|
40
|
+
const problems = selectedReferences(targets).flatMap(
|
|
41
|
+
({ label, reference }) => {
|
|
42
|
+
const identity = brokeredReferenceIdentity(reference);
|
|
43
|
+
if (!allowed.has(identity)) {
|
|
44
|
+
return [
|
|
45
|
+
`${label}: unauthorized brokered S3 pair; add "${identity}" to the encrypted secrets/dev.yaml brokeredReferences allowlist`,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
if (preserved.has(identity)) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const resolved = resolveBrokeredSource(consumer, reference);
|
|
52
|
+
if (!resolved.ok) {
|
|
53
|
+
return [`${label}: ${resolved.problem}`];
|
|
54
|
+
}
|
|
55
|
+
if (reference.source !== undefined) {
|
|
56
|
+
const previous = roots.get(reference.source.repository);
|
|
57
|
+
if (previous !== undefined && previous !== resolved.root) {
|
|
58
|
+
return [
|
|
59
|
+
`${label}: one source repository must resolve to one checkout per generation`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
roots.set(reference.source.repository, resolved.root);
|
|
63
|
+
}
|
|
64
|
+
sources.set(reference, resolved);
|
|
65
|
+
return [];
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
return { sources, problems };
|
|
69
|
+
};
|