@passioncode-ai/fabric-agent-adapter 0.24.0 → 0.26.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/CHANGELOG.md +71 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +17 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +182 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +32 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +156 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +128 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +11 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +182 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +176 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Identifier equality (contract DEC-0076): two identifiers are the same when their normal forms are equal. A port of the
|
|
2
|
+
// contract's src/identifiers.ts (types removed), held to its fixtures/vectors/identifiers.json; the twin of
|
|
3
|
+
// fabric_identifiers.py. Nothing here throws.
|
|
4
|
+
|
|
5
|
+
const UNRESERVED = "A-Za-z0-9\\-._~";
|
|
6
|
+
const SUB_DELIMS = "!$&'()*+,;=";
|
|
7
|
+
const PCT = "%[0-9A-Fa-f]{2}";
|
|
8
|
+
/** `*( <chars> / pct-encoded )` — each a single character class beside one fixed triplet, so the match is linear. */
|
|
9
|
+
const run = (chars) => new RegExp(`^(?:[${chars}]|${PCT})*$`);
|
|
10
|
+
const USERINFO = run(`${UNRESERVED}${SUB_DELIMS}:`);
|
|
11
|
+
const REG_NAME = run(`${UNRESERVED}${SUB_DELIMS}`);
|
|
12
|
+
const PATH = run(`${UNRESERVED}${SUB_DELIMS}:@/`);
|
|
13
|
+
const QUERY = run(`${UNRESERVED}${SUB_DELIMS}:@/?`);
|
|
14
|
+
const PORT = /^[0-9]*$/;
|
|
15
|
+
const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/;
|
|
16
|
+
|
|
17
|
+
// RFC 3986 §3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]".
|
|
18
|
+
const H16 = "[0-9A-Fa-f]{1,4}";
|
|
19
|
+
const DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])";
|
|
20
|
+
const IPV4 = `${DEC_OCTET}(?:\\.${DEC_OCTET}){3}`;
|
|
21
|
+
const LS32 = `(?:${H16}:${H16}|${IPV4})`;
|
|
22
|
+
const IPV6 = [
|
|
23
|
+
`(?:${H16}:){6}${LS32}`,
|
|
24
|
+
`::(?:${H16}:){5}${LS32}`,
|
|
25
|
+
`(?:${H16})?::(?:${H16}:){4}${LS32}`,
|
|
26
|
+
`(?:(?:${H16}:){0,1}${H16})?::(?:${H16}:){3}${LS32}`,
|
|
27
|
+
`(?:(?:${H16}:){0,2}${H16})?::(?:${H16}:){2}${LS32}`,
|
|
28
|
+
`(?:(?:${H16}:){0,3}${H16})?::${H16}:${LS32}`,
|
|
29
|
+
`(?:(?:${H16}:){0,4}${H16})?::${LS32}`,
|
|
30
|
+
`(?:(?:${H16}:){0,5}${H16})?::${H16}`,
|
|
31
|
+
`(?:(?:${H16}:){0,6}${H16})?::`,
|
|
32
|
+
].join("|");
|
|
33
|
+
const IP_LITERAL = new RegExp(`^\\[(?:${IPV6}|[Vv][0-9A-Fa-f]+\\.[${UNRESERVED}${SUB_DELIMS}:]+)\\]$`);
|
|
34
|
+
|
|
35
|
+
// RFC 8141 §2: NID = (alphanum) 0*30(ldh) (alphanum), then ":" and a non-empty NSS.
|
|
36
|
+
const URN_NAME = /^([A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]):(.+)$/s;
|
|
37
|
+
|
|
38
|
+
/** Percent-encoding hex upper-cased (§6.2.2.1), and unreserved characters decoded unless `keepEncoded` (§6.2.2.2). */
|
|
39
|
+
function percent(text, keepEncoded = false) {
|
|
40
|
+
return text.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => {
|
|
41
|
+
const char = String.fromCharCode(Number.parseInt(hex, 16));
|
|
42
|
+
return !keepEncoded && /^[A-Za-z0-9\-._~]$/.test(char) ? char : `%${hex.toUpperCase()}`;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A rootless path (`agent:./x`) loses its leading "./" and "../" segments — §5.2.4's step A — while what is left stays
|
|
48
|
+
* rootless: not empty and not beginning with "/". Dot segments further in are kept, because removing them by §5.2.4 can
|
|
49
|
+
* turn a rootless path into an absolute one (`a/../b` → `/b`). That narrowing is the contract's own, under RFC 3986
|
|
50
|
+
* §6.1's aim of no false positives (DEC-0076).
|
|
51
|
+
*/
|
|
52
|
+
function stripLeadingDots(path) {
|
|
53
|
+
let start = 0;
|
|
54
|
+
while (path.startsWith("./", start) || path.startsWith("../", start)) start += path.startsWith("./", start) ? 2 : 3;
|
|
55
|
+
const rest = path.slice(start);
|
|
56
|
+
return rest === "" || rest.startsWith("/") ? path : rest;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* RFC 3986 §5.2.4 remove_dot_segments, over a path whose `.` and `..` are already decoded. Its input buffer is read
|
|
61
|
+
* segment by segment: the path split on "/" gives the same steps without copying the rest of the input at each one.
|
|
62
|
+
*/
|
|
63
|
+
function removeDotSegments(path) {
|
|
64
|
+
const absolute = path.startsWith("/");
|
|
65
|
+
const segments = (absolute ? path.slice(1) : path).split("/");
|
|
66
|
+
const output = [];
|
|
67
|
+
segments.forEach((segment, index) => {
|
|
68
|
+
const last = index === segments.length - 1;
|
|
69
|
+
if (segment === "." || segment === "..") {
|
|
70
|
+
if (segment === ".." && output.length > 0) output.pop();
|
|
71
|
+
// A dot segment that ends the path leaves the path ending in "/" (§5.2.4 steps B and C on "/." and "/..").
|
|
72
|
+
if (last) output.push("");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
output.push(segment);
|
|
76
|
+
});
|
|
77
|
+
return (absolute ? "/" : "") + output.join("/");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/** RFC 3986 Appendix B's split, then each component checked against its §3 production; undefined when one fails. */
|
|
82
|
+
function parse(text) {
|
|
83
|
+
const match = /^([^:/?#]+):(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/s.exec(text);
|
|
84
|
+
if (!match) return undefined;
|
|
85
|
+
const [, scheme, authority, path, query, fragment] = match;
|
|
86
|
+
if (!SCHEME.test(scheme) || !PATH.test(path)) return undefined;
|
|
87
|
+
if (query !== undefined && !QUERY.test(query)) return undefined;
|
|
88
|
+
if (fragment !== undefined && !QUERY.test(fragment)) return undefined;
|
|
89
|
+
if (authority !== undefined && !authorityParts(authority)) return undefined;
|
|
90
|
+
// An empty path with no authority (`x:`, RFC 3986's path-empty) is left as it is: the schema's `format: uri` refuses
|
|
91
|
+
// it, so no admitted value has it. (A path never begins with "//" here: Appendix B's split reads that as an authority.)
|
|
92
|
+
if (authority === undefined && path === "") return undefined;
|
|
93
|
+
return { scheme, authority, path, query, fragment };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function authorityParts(authority) {
|
|
97
|
+
const at = authority.lastIndexOf("@");
|
|
98
|
+
const userinfo = at === -1 ? undefined : authority.slice(0, at);
|
|
99
|
+
const hostPort = at === -1 ? authority : authority.slice(at + 1);
|
|
100
|
+
const bracket = hostPort.startsWith("[") ? hostPort.indexOf("]") : -1;
|
|
101
|
+
const colon = hostPort.indexOf(":", bracket === -1 ? 0 : bracket);
|
|
102
|
+
const host = colon === -1 ? hostPort : hostPort.slice(0, colon);
|
|
103
|
+
const port = colon === -1 ? undefined : hostPort.slice(colon + 1);
|
|
104
|
+
if (userinfo !== undefined && !USERINFO.test(userinfo)) return undefined;
|
|
105
|
+
if (port !== undefined && !PORT.test(port)) return undefined;
|
|
106
|
+
if (host.startsWith("[") ? !IP_LITERAL.test(host) : !REG_NAME.test(host)) return undefined;
|
|
107
|
+
return { userinfo, host, port };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** RFC 8141 §3.1: `urn` and the NID lower-cased, hex upper-cased, nothing decoded; r-, q- and f-components ignored. */
|
|
111
|
+
function urnForm(parts) {
|
|
112
|
+
const name = URN_NAME.exec(parts.path);
|
|
113
|
+
const path = name ? `${name[1].toLowerCase()}:${percent(name[2], true)}` : percent(parts.path, true);
|
|
114
|
+
// A "?" not followed by "+" or "=" is no URN component (RFC 8141 §2): it is kept, never dropped unread.
|
|
115
|
+
const query = parts.query !== undefined && !/^[+=]/.test(parts.query) ? `?${percent(parts.query, true)}` : "";
|
|
116
|
+
const authority = parts.authority !== undefined ? `//${percent(parts.authority, true)}` : "";
|
|
117
|
+
return `urn:${authority}${path}${query}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function genericForm(parts) {
|
|
121
|
+
let authority = "";
|
|
122
|
+
if (parts.authority !== undefined) {
|
|
123
|
+
const { userinfo, host, port } = authorityParts(parts.authority);
|
|
124
|
+
// The host is case-insensitive (§3.2.2); its remaining percent triplets keep upper-case hex.
|
|
125
|
+
const lowered = percent(host).toLowerCase().replace(/%[0-9a-f]{2}/g, (triplet) => triplet.toUpperCase());
|
|
126
|
+
authority = `//${userinfo !== undefined ? `${percent(userinfo)}@` : ""}${lowered}${port !== undefined ? `:${port}` : ""}`;
|
|
127
|
+
}
|
|
128
|
+
const decoded = percent(parts.path);
|
|
129
|
+
const hierarchical = parts.authority !== undefined || decoded.startsWith("/");
|
|
130
|
+
let path = hierarchical ? removeDotSegments(decoded) : stripLeadingDots(decoded);
|
|
131
|
+
// A path left starting with "//" and no authority would read as one: keep the decoded path instead.
|
|
132
|
+
if (parts.authority === undefined && path.startsWith("//")) path = decoded;
|
|
133
|
+
const query = parts.query !== undefined ? `?${percent(parts.query)}` : "";
|
|
134
|
+
const fragment = parts.fragment !== undefined ? `#${percent(parts.fragment)}` : "";
|
|
135
|
+
return `${parts.scheme.toLowerCase()}:${authority}${path}${query}${fragment}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The normal form of an identifier: a URI's syntax-normalized form (RFC 3986 §6.2.2; RFC 8141 §3.1 for `urn:`), any
|
|
140
|
+
* other string as it is, and undefined for a value that is not a string.
|
|
141
|
+
*/
|
|
142
|
+
export function normalIdentifier(value) {
|
|
143
|
+
if (typeof value !== "string") return undefined;
|
|
144
|
+
const parts = parse(value);
|
|
145
|
+
if (!parts) return value;
|
|
146
|
+
return parts.scheme.toLowerCase() === "urn" ? urnForm(parts) : genericForm(parts);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Two identifiers are the same when their normal forms are equal (DEC-0076). A value that is not a string is no
|
|
151
|
+
* identifier and is compared as it is (`===`), so a rule reads a schema-invalid member exactly as it did before.
|
|
152
|
+
*/
|
|
153
|
+
export function sameIdentifier(a, b) {
|
|
154
|
+
if (typeof a !== "string" || typeof b !== "string") return a === b;
|
|
155
|
+
return normalIdentifier(a) === normalIdentifier(b);
|
|
156
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// the well-known `configuration` with its summary tiles ("Runner & model"). No dependencies.
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { normalIdentifier, sameIdentifier } from './fabric-identifiers.mjs';
|
|
7
8
|
import fs from 'node:fs';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import { secretValue } from './fabric-secrets.mjs';
|
|
@@ -210,7 +211,8 @@ export const PROBE_RESULTS = Object.freeze(['not-catalogued', 'not-admitted', 'n
|
|
|
210
211
|
|
|
211
212
|
const routeCandidates = (value) => (Array.isArray(value?.candidates) ? value.candidates.filter(isObject) : []);
|
|
212
213
|
const objectAt = (value, key) => (isObject(value?.[key]) ? value[key] : {});
|
|
213
|
-
|
|
214
|
+
// A revision reference compared whole; its id is an identifier, so it enters the key in its normal form (DEC-0076).
|
|
215
|
+
const refKey = (ref) => (isObject(ref) ? `${String(normalIdentifier(ref.id) ?? ref.id)}@${String(ref.revision)}#${String(ref.contentHash)}` : '');
|
|
214
216
|
const sameRef = (a, b) => refKey(a) !== '' && refKey(a) === refKey(b);
|
|
215
217
|
|
|
216
218
|
function runnerRoute(value) {
|
|
@@ -229,7 +231,7 @@ function runnerRoute(value) {
|
|
|
229
231
|
function routeBundle(value) {
|
|
230
232
|
const route = objectAt(value, 'route');
|
|
231
233
|
const admissions = Array.isArray(value.admissions) ? value.admissions.filter(isObject) : [];
|
|
232
|
-
const admitted = new Set(admissions.filter((a) => a.state === 'admitted' && a.capability
|
|
234
|
+
const admitted = new Set(admissions.filter((a) => a.state === 'admitted' && sameIdentifier(a.capability, route.capability)).map((a) => refKey(a.provider)));
|
|
233
235
|
const findings = [];
|
|
234
236
|
routeCandidates(route).forEach((candidate, index) => {
|
|
235
237
|
const key = refKey(candidate.provider);
|
|
@@ -238,8 +240,9 @@ function routeBundle(value) {
|
|
|
238
240
|
if (isObject(value.binding)) {
|
|
239
241
|
const binding = value.binding;
|
|
240
242
|
const disagree = (where, message) => findings.push(finding('FAC-SEM-032', `/binding/${where}`, message));
|
|
241
|
-
|
|
242
|
-
if (binding.
|
|
243
|
+
// DEC-0076: project and capability are identifiers, compared by normal form.
|
|
244
|
+
if (!sameIdentifier(binding.project, route.project)) disagree('project', `the binding is for ${String(binding.project)}, the route for ${String(route.project)}`);
|
|
245
|
+
if (!sameIdentifier(binding.capability, route.capability)) disagree('capability', `the binding binds ${String(binding.capability)}, the route orders ${String(route.capability)}`);
|
|
243
246
|
if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${String(binding.profileKind)}`);
|
|
244
247
|
if (!routeCandidates(route).some((c) => sameRef(c.provider, binding.provider))) disagree('provider', `the binding's provider ${refKey(binding.provider)} is not a candidate of the route`);
|
|
245
248
|
if (binding.runnerRoute !== undefined && !sameRef(binding.runnerRoute, route.meta)) disagree('runnerRoute', `the binding pins ${refKey(binding.runnerRoute)}, not this route ${refKey(route.meta)}`);
|
|
@@ -263,8 +266,8 @@ function routeEvent(value) {
|
|
|
263
266
|
const findings = [];
|
|
264
267
|
const wrong = (where, message) => findings.push(finding('FAC-SEM-034', `/event${where}`, message));
|
|
265
268
|
if (!sameRef(event.route, route.meta)) wrong('/route', `the event names route ${refKey(event.route)}, not ${refKey(route.meta)}`);
|
|
266
|
-
if (event.project
|
|
267
|
-
if (event.capability
|
|
269
|
+
if (!sameIdentifier(event.project, route.project)) wrong('/project', `the event is for ${String(event.project)}, the route for ${String(route.project)}`);
|
|
270
|
+
if (!sameIdentifier(event.capability, route.capability)) wrong('/capability', `the event names ${String(event.capability)}, the route orders ${String(route.capability)}`);
|
|
268
271
|
const indexOf = (ref) => (typeof ref.index === 'number' && Number.isInteger(ref.index) ? ref.index : -1);
|
|
269
272
|
const candidateAt = (ref, where) => {
|
|
270
273
|
const index = indexOf(ref);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import fs from 'node:fs';
|
|
|
9
9
|
import net from 'node:net';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import { credentialName, secretArgument, secretValue } from './fabric-secrets.mjs';
|
|
12
|
+
import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
13
13
|
|
|
14
14
|
export const PROTOCOL = 'fabric-service/0.1';
|
|
15
15
|
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
|
|
@@ -818,7 +818,7 @@ export function configurationProblems(doc) {
|
|
|
818
818
|
return problems;
|
|
819
819
|
}
|
|
820
820
|
|
|
821
|
-
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration }) {
|
|
821
|
+
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols }) {
|
|
822
822
|
if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
|
|
823
823
|
if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
|
|
824
824
|
if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
|
|
@@ -844,12 +844,132 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
844
844
|
if (problems.length) throw new ServiceError(`configuration: ${problems.join('; ')}.`);
|
|
845
845
|
doc.configuration = configuration;
|
|
846
846
|
}
|
|
847
|
+
if (protocols !== undefined) {
|
|
848
|
+
// DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so a
|
|
849
|
+
// service lists them only once its hosts are pinned at or after it.
|
|
850
|
+
const problems = protocolsFindings(protocols);
|
|
851
|
+
if (problems.length) throw new ServiceError(`protocols: ${problems.map((f) => f.message).join('; ')}.`);
|
|
852
|
+
doc.protocols = [...protocols];
|
|
853
|
+
}
|
|
847
854
|
return doc;
|
|
848
855
|
}
|
|
849
856
|
|
|
850
857
|
// An event about traced work carries traceId and spanId together (fabric-interop/0.1 C3.4 c);
|
|
851
858
|
// traceIds(traceparent) in fabric-interop.mjs gives both.
|
|
852
|
-
|
|
859
|
+
// --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data ---------------------------------------
|
|
860
|
+
|
|
861
|
+
/** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
|
|
862
|
+
export const PROTOCOL_REVISIONS = new Map(Object.entries({
|
|
863
|
+
'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
|
|
864
|
+
'fabric-activity': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
|
|
865
|
+
'fabric-interop': ['0.1'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
|
|
866
|
+
'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
|
|
867
|
+
'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
|
|
868
|
+
memory: ['0.1'], pipeline: ['0.1'], 'revocation-delta': ['1'], 'session-handoff': ['1'], 'window-lease': ['1'],
|
|
869
|
+
}));
|
|
870
|
+
export const MAX_PROTOCOLS = 64;
|
|
871
|
+
const REVISION = /^([a-z][a-z0-9.-]{0,63})\/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})$/;
|
|
872
|
+
|
|
873
|
+
/** `<name>/<revision>` split, or undefined. */
|
|
874
|
+
export function parseRevision(value) {
|
|
875
|
+
const m = typeof value === 'string' ? REVISION.exec(value) : null;
|
|
876
|
+
return m ? { name: m[1], revision: m[2] } : undefined;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** The revisions a receiver implementing `name` up to `newest` accepts: N and, when the register has one, N−1. */
|
|
880
|
+
export function acceptedRevisions(name, newest) {
|
|
881
|
+
const known = PROTOCOL_REVISIONS.get(name);
|
|
882
|
+
if (!known?.length) return [];
|
|
883
|
+
const top = newest === undefined ? known.length - 1 : known.indexOf(newest);
|
|
884
|
+
if (top < 0) return [];
|
|
885
|
+
return known.slice(Math.max(0, top - 1), top + 1).reverse().map((r) => `${name}/${r}`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* A receiver's answer to a document naming `received` (DEC-0067, the contract's `negotiateRevision`): `{ok: true}`, the
|
|
890
|
+
* `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted revisions of that
|
|
891
|
+
* protocol (all of them when it names none the receiver reads), or `{fault: 'no-accepted-revisions'}`.
|
|
892
|
+
*/
|
|
893
|
+
export function negotiateRevision(received, accepted) {
|
|
894
|
+
const usable = [...new Set((Array.isArray(accepted) ? accepted : []).filter((e) => parseRevision(e) !== undefined))].slice(0, MAX_PROTOCOLS);
|
|
895
|
+
if (!usable.length) return { fault: 'no-accepted-revisions' };
|
|
896
|
+
if (typeof received === 'string' && usable.includes(received)) return { ok: true };
|
|
897
|
+
const parsed = parseRevision(received);
|
|
898
|
+
const same = parsed ? usable.filter((e) => parseRevision(e)?.name === parsed.name) : [];
|
|
899
|
+
return { error: { code: 'schema-unsupported', message: 'this receiver does not read that protocol revision',
|
|
900
|
+
data: { protocol: typeof received === 'string' ? Array.from(received).slice(0, 80).join('') : '', accepted: same.length ? same : usable } } };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** FAC-SEM-099 (DEC-0067): 1 to 64 entries, each once; at most N and N−1 of one protocol, consecutive in the register. */
|
|
904
|
+
export function protocolsFindings(protocols, prefix = '/protocols') {
|
|
905
|
+
const flag = (instancePath, message) => ({ code: 'FAC-SEM-099', instancePath, message });
|
|
906
|
+
if (!Array.isArray(protocols)) return [flag(prefix, 'protocols is a list of protocol revisions')];
|
|
907
|
+
if (protocols.length === 0 || protocols.length > MAX_PROTOCOLS) return [flag(prefix, `protocols lists 1 to ${MAX_PROTOCOLS} revisions`)];
|
|
908
|
+
const findings = [];
|
|
909
|
+
const byName = new Map();
|
|
910
|
+
const seen = new Set();
|
|
911
|
+
protocols.forEach((entry, i) => {
|
|
912
|
+
const at = `${prefix}/${i}`;
|
|
913
|
+
const parsed = parseRevision(entry);
|
|
914
|
+
if (!parsed) { findings.push(flag(at, 'a protocol revision is <name>/<revision>, such as fabric-service/0.1')); return; }
|
|
915
|
+
if (seen.has(entry)) { findings.push(flag(at, 'each protocol revision once')); return; }
|
|
916
|
+
seen.add(entry);
|
|
917
|
+
const known = PROTOCOL_REVISIONS.get(parsed.name);
|
|
918
|
+
if (known && !known.includes(parsed.revision)) { findings.push(flag(at, `${parsed.name} has no published revision ${parsed.revision}`)); return; }
|
|
919
|
+
const indices = byName.get(parsed.name) ?? [];
|
|
920
|
+
indices.push(known ? known.indexOf(parsed.revision) : -1);
|
|
921
|
+
byName.set(parsed.name, indices);
|
|
922
|
+
if (indices.length > 2) findings.push(flag(at, 'at most 2 revisions of one protocol: N and N−1'));
|
|
923
|
+
else if (known && indices.length === 2 && Math.abs(indices[0] - indices[1]) !== 1) findings.push(flag(at, 'two revisions of one protocol are N and N−1, consecutive in the register'));
|
|
924
|
+
});
|
|
925
|
+
return findings;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const SCHEMA_URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
|
|
929
|
+
const scalar = (v) => v === null || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && Array.from(v).length <= 500);
|
|
930
|
+
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
931
|
+
const pointerToken = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
932
|
+
|
|
933
|
+
/** FAC-SEM-101 over an events page (the contract's `feedDataFindings`): no credential's name or shape in an event's data. */
|
|
934
|
+
export function feedDataFindings(page) {
|
|
935
|
+
const findings = [];
|
|
936
|
+
const check = (named, member, at) => {
|
|
937
|
+
if (typeof member !== 'string') return;
|
|
938
|
+
if (named) findings.push({ code: 'FAC-SEM-101', instancePath: at, message: "an event's data puts no string under a credential's name" });
|
|
939
|
+
else if (secretValue(member) || secretUrl(member)) findings.push({ code: 'FAC-SEM-101', instancePath: at, message: "an event's data carries no value shaped like a credential" });
|
|
940
|
+
};
|
|
941
|
+
(Array.isArray(page?.events) ? page.events : []).forEach((event, i) => {
|
|
942
|
+
if (!isPlain(event) || !isPlain(event.data) || !isPlain(event.data.value)) return;
|
|
943
|
+
for (const [key, member] of Object.entries(event.data.value)) {
|
|
944
|
+
const at = `/events/${i}/data/value/${pointerToken(key)}`;
|
|
945
|
+
const named = credentialName(key);
|
|
946
|
+
check(named, member, at);
|
|
947
|
+
if (Array.isArray(member)) member.forEach((item, j) => check(named, item, `${at}/${j}`));
|
|
948
|
+
else if (isPlain(member)) for (const [inner, leaf] of Object.entries(member)) check(named || credentialName(inner), leaf, `${at}/${pointerToken(inner)}`);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
return findings;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101. */
|
|
955
|
+
export function feedDataProblems(data) {
|
|
956
|
+
if (!isPlain(data) || Object.keys(data).length !== 2 || !('schema' in data) || !('value' in data)) return ['data is {schema, value}'];
|
|
957
|
+
const problems = [];
|
|
958
|
+
if (!(typeof data.schema === 'string' && SCHEMA_URI.test(data.schema))) problems.push('data.schema is the absolute URI of a payload schema the service publishes');
|
|
959
|
+
const value = data.value;
|
|
960
|
+
if (!isPlain(value) || Object.keys(value).length > 32) return [...problems, 'data.value is an object of at most 32 members'];
|
|
961
|
+
for (const [key, member] of Object.entries(value)) {
|
|
962
|
+
if (scalar(member)) continue;
|
|
963
|
+
if (Array.isArray(member) && member.length <= 32 && member.every(scalar)) continue;
|
|
964
|
+
if (isPlain(member) && Object.keys(member).length <= 32 && Object.values(member).every(scalar)) continue;
|
|
965
|
+
problems.push(`data.value.${key} is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members`);
|
|
966
|
+
}
|
|
967
|
+
if (problems.length) return problems;
|
|
968
|
+
return feedDataFindings({ events: [{ data }] }).map((f) => `FAC-SEM-101: ${f.instancePath.replace('/events/0', '')} ${f.message}`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** One activity event. `data` (DEC-0070) adds machine-readable fields, `{schema, value}`, never a secret: refused here, so no feed carries one. */
|
|
972
|
+
export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId, data } = {}) {
|
|
853
973
|
if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
|
|
854
974
|
if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
|
|
855
975
|
const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
|
|
@@ -862,6 +982,11 @@ export function makeEvent(id, at, kind, level, text, { subject, link, notify, tr
|
|
|
862
982
|
if (link) event.link = link;
|
|
863
983
|
if (notify) event.notify = true;
|
|
864
984
|
if (traceId !== undefined) Object.assign(event, { traceId, spanId });
|
|
985
|
+
if (data !== undefined) {
|
|
986
|
+
const problems = feedDataProblems(data);
|
|
987
|
+
if (problems.length) throw new ServiceError(`Event data: ${problems.join('; ')}.`);
|
|
988
|
+
event.data = { schema: data.schema, value: data.value };
|
|
989
|
+
}
|
|
865
990
|
return event;
|
|
866
991
|
}
|
|
867
992
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py
CHANGED
|
@@ -826,15 +826,21 @@ def _secret_references(value: Dict[str, Any], flag) -> None:
|
|
|
826
826
|
slots.append(key)
|
|
827
827
|
|
|
828
828
|
|
|
829
|
+
def _grant_key(ident: Any) -> Any:
|
|
830
|
+
"""A grant's id is a URI, so grants and their revocations are keyed by its normal form (DEC-0076)."""
|
|
831
|
+
import fabric_identifiers # noqa: E402 - beside this file
|
|
832
|
+
return fabric_identifiers.normal_identifier(ident) if isinstance(ident, str) else ident
|
|
833
|
+
|
|
834
|
+
|
|
829
835
|
def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
830
836
|
grant_ids: List[Any] = []
|
|
831
837
|
for index, grant in enumerate(_list(_get(value, "shareGrants"))):
|
|
832
838
|
if _get(grant, "principalKind") != "external":
|
|
833
839
|
flag("/shareGrants/%d/principalKind" % index, "a share grant is to a principal outside the estate: principalKind external")
|
|
834
840
|
ident = _get(_get(grant, "meta"), "id") if isinstance(_get(grant, "meta"), dict) else _MISSING
|
|
835
|
-
if _contains(grant_ids, ident):
|
|
841
|
+
if _contains(grant_ids, _grant_key(ident)):
|
|
836
842
|
flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" % _js_string(ident))
|
|
837
|
-
grant_ids.append(ident)
|
|
843
|
+
grant_ids.append(_grant_key(ident))
|
|
838
844
|
machines = _list(_get(value, "machines"))
|
|
839
845
|
listed = {
|
|
840
846
|
"authority": [_get(e, "credentialId") for e in _list(_get(value, "authorities"))],
|
|
@@ -847,11 +853,12 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
|
847
853
|
}
|
|
848
854
|
seen: List[str] = []
|
|
849
855
|
for index, revocation in enumerate(_list(_get(value, "revocations"))):
|
|
850
|
-
|
|
856
|
+
ref = _grant_key(_get(revocation, "ref")) if _get(revocation, "kind") == "grant" else _get(revocation, "ref")
|
|
857
|
+
key = "%s %s" % (_s(revocation, "kind"), _js_string(ref))
|
|
851
858
|
if key in seen:
|
|
852
859
|
flag("/revocations/%d" % index, "the %s %s is revoked twice" % (_s(revocation, "kind"), _s(revocation, "ref")))
|
|
853
860
|
seen.append(key)
|
|
854
|
-
if _contains(listed.get(_s(revocation, "kind"), []),
|
|
861
|
+
if _contains(listed.get(_s(revocation, "kind"), []), ref):
|
|
855
862
|
flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % _s(revocation, "kind"))
|
|
856
863
|
|
|
857
864
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Identifier equality (contract DEC-0076): two identifiers are the same when their normal forms are equal.
|
|
2
|
+
|
|
3
|
+
A port of the contract's `src/identifiers.ts`, held to its `fixtures/vectors/identifiers.json`. The normal form of a URI
|
|
4
|
+
is RFC 3986 §6.2.2 syntax-based normalization: the scheme and the host lower-cased and percent-encoding hex upper-cased
|
|
5
|
+
(§6.2.2.1), percent-encoded unreserved characters decoded (§6.2.2.2), dot segments removed from a hierarchical path
|
|
6
|
+
(§6.2.2.3), and only the leading ones from a rootless path; nothing scheme-based (§6.2.3). A `urn:` URI follows RFC 8141
|
|
7
|
+
§3.1 instead: the scheme and the NID lower-cased, percent-encoding hex upper-cased, nothing decoded, the NSS's case kept,
|
|
8
|
+
the r-, q- and f-components ignored. A string that is not a URI by RFC 3986's `URI` production is its own normal form.
|
|
9
|
+
Nothing here raises. Standard library only; this file is byte-identical in both skills' `scripts/`."""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
_UNRESERVED = r"A-Za-z0-9\-._~"
|
|
16
|
+
_SUB_DELIMS = r"!$&'()*+,;="
|
|
17
|
+
_PCT = r"%[0-9A-Fa-f]{2}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run(chars: str) -> "re.Pattern[str]":
|
|
21
|
+
"""`*( <chars> / pct-encoded )`, linear: one character class beside one fixed triplet."""
|
|
22
|
+
return re.compile(r"(?:[%s]|%s)*" % (chars, _PCT), re.ASCII)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_USERINFO = _run(_UNRESERVED + _SUB_DELIMS + ":")
|
|
26
|
+
_REG_NAME = _run(_UNRESERVED + _SUB_DELIMS)
|
|
27
|
+
_PATH = _run(_UNRESERVED + _SUB_DELIMS + ":@/")
|
|
28
|
+
_QUERY = _run(_UNRESERVED + _SUB_DELIMS + ":@/?")
|
|
29
|
+
_PORT = re.compile(r"[0-9]*", re.ASCII)
|
|
30
|
+
_SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*", re.ASCII)
|
|
31
|
+
|
|
32
|
+
# RFC 3986 §3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]".
|
|
33
|
+
_H16 = "[0-9A-Fa-f]{1,4}"
|
|
34
|
+
_DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
|
|
35
|
+
_IPV4 = r"%s(?:\.%s){3}" % (_DEC_OCTET, _DEC_OCTET)
|
|
36
|
+
_LS32 = "(?:%s:%s|%s)" % (_H16, _H16, _IPV4)
|
|
37
|
+
_IPV6 = "|".join([
|
|
38
|
+
"(?:%s:){6}%s" % (_H16, _LS32),
|
|
39
|
+
"::(?:%s:){5}%s" % (_H16, _LS32),
|
|
40
|
+
"(?:%s)?::(?:%s:){4}%s" % (_H16, _H16, _LS32),
|
|
41
|
+
"(?:(?:%s:){0,1}%s)?::(?:%s:){3}%s" % (_H16, _H16, _H16, _LS32),
|
|
42
|
+
"(?:(?:%s:){0,2}%s)?::(?:%s:){2}%s" % (_H16, _H16, _H16, _LS32),
|
|
43
|
+
"(?:(?:%s:){0,3}%s)?::%s:%s" % (_H16, _H16, _H16, _LS32),
|
|
44
|
+
"(?:(?:%s:){0,4}%s)?::%s" % (_H16, _H16, _LS32),
|
|
45
|
+
"(?:(?:%s:){0,5}%s)?::%s" % (_H16, _H16, _H16),
|
|
46
|
+
"(?:(?:%s:){0,6}%s)?::" % (_H16, _H16),
|
|
47
|
+
])
|
|
48
|
+
_IP_LITERAL = re.compile(r"\[(?:%s|[Vv][0-9A-Fa-f]+\.[%s%s:]+)\]" % (_IPV6, _UNRESERVED, _SUB_DELIMS), re.ASCII)
|
|
49
|
+
|
|
50
|
+
# RFC 8141 §2: NID = (alphanum) 0*30(ldh) (alphanum), then ":" and a non-empty NSS.
|
|
51
|
+
_URN_NAME = re.compile(r"([A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]):(.+)", re.ASCII | re.DOTALL)
|
|
52
|
+
_SPLIT = re.compile(r"([^:/?#]+):(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?", re.DOTALL)
|
|
53
|
+
_TRIPLET = re.compile(r"%([0-9A-Fa-f]{2})")
|
|
54
|
+
_UNRESERVED_CHAR = re.compile(r"[A-Za-z0-9\-._~]", re.ASCII)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _percent(text: str, keep_encoded: bool = False) -> str:
|
|
58
|
+
"""Percent-encoding hex upper-cased (§6.2.2.1), and unreserved characters decoded unless ``keep_encoded``."""
|
|
59
|
+
def one(match: "re.Match[str]") -> str:
|
|
60
|
+
char = chr(int(match.group(1), 16))
|
|
61
|
+
return char if not keep_encoded and _UNRESERVED_CHAR.fullmatch(char) else "%" + match.group(1).upper()
|
|
62
|
+
return _TRIPLET.sub(one, text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _strip_leading_dots(path: str) -> str:
|
|
66
|
+
"""A rootless path loses its leading "./" and "../" segments while what is left stays rootless (DEC-0076)."""
|
|
67
|
+
start = 0
|
|
68
|
+
while path.startswith("./", start) or path.startswith("../", start):
|
|
69
|
+
start += 2 if path.startswith("./", start) else 3
|
|
70
|
+
rest = path[start:]
|
|
71
|
+
return path if rest == "" or rest.startswith("/") else rest
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _remove_dot_segments(path: str) -> str:
|
|
75
|
+
"""RFC 3986 §5.2.4 remove_dot_segments over a path whose "." and ".." are already decoded."""
|
|
76
|
+
absolute = path.startswith("/")
|
|
77
|
+
segments = (path[1:] if absolute else path).split("/")
|
|
78
|
+
output = []
|
|
79
|
+
for index, segment in enumerate(segments):
|
|
80
|
+
last = index == len(segments) - 1
|
|
81
|
+
if segment in (".", ".."):
|
|
82
|
+
if segment == ".." and output:
|
|
83
|
+
output.pop()
|
|
84
|
+
if last: # a dot segment that ends the path leaves it ending in "/"
|
|
85
|
+
output.append("")
|
|
86
|
+
continue
|
|
87
|
+
output.append(segment)
|
|
88
|
+
return ("/" if absolute else "") + "/".join(output)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _authority_parts(authority: str) -> Optional[Tuple[Optional[str], str, Optional[str]]]:
|
|
92
|
+
at = authority.rfind("@")
|
|
93
|
+
userinfo = None if at == -1 else authority[:at]
|
|
94
|
+
host_port = authority if at == -1 else authority[at + 1:]
|
|
95
|
+
bracket = host_port.find("]") if host_port.startswith("[") else -1
|
|
96
|
+
colon = host_port.find(":", 0 if bracket == -1 else bracket)
|
|
97
|
+
host = host_port if colon == -1 else host_port[:colon]
|
|
98
|
+
port = None if colon == -1 else host_port[colon + 1:]
|
|
99
|
+
if userinfo is not None and not _USERINFO.fullmatch(userinfo):
|
|
100
|
+
return None
|
|
101
|
+
if port is not None and not _PORT.fullmatch(port):
|
|
102
|
+
return None
|
|
103
|
+
if not (_IP_LITERAL.fullmatch(host) if host.startswith("[") else _REG_NAME.fullmatch(host)):
|
|
104
|
+
return None
|
|
105
|
+
return userinfo, host, port
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse(text: str) -> Optional[Tuple[str, Optional[str], str, Optional[str], Optional[str]]]:
|
|
109
|
+
"""RFC 3986 Appendix B's split, then each component checked against its §3 production; None when one fails."""
|
|
110
|
+
match = _SPLIT.fullmatch(text)
|
|
111
|
+
if not match:
|
|
112
|
+
return None
|
|
113
|
+
scheme, authority, path, query, fragment = match.groups()
|
|
114
|
+
path = path or ""
|
|
115
|
+
if not _SCHEME.fullmatch(scheme) or not _PATH.fullmatch(path):
|
|
116
|
+
return None
|
|
117
|
+
if query is not None and not _QUERY.fullmatch(query):
|
|
118
|
+
return None
|
|
119
|
+
if fragment is not None and not _QUERY.fullmatch(fragment):
|
|
120
|
+
return None
|
|
121
|
+
if authority is not None and _authority_parts(authority) is None:
|
|
122
|
+
return None
|
|
123
|
+
if authority is None and path == "":
|
|
124
|
+
return None
|
|
125
|
+
return scheme, authority, path, query, fragment
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _urn_form(authority: Optional[str], path: str, query: Optional[str]) -> str:
|
|
129
|
+
name = _URN_NAME.fullmatch(path)
|
|
130
|
+
path = "%s:%s" % (name.group(1).lower(), _percent(name.group(2), True)) if name else _percent(path, True)
|
|
131
|
+
# A "?" not followed by "+" or "=" is no URN component (RFC 8141 §2): it is kept, never dropped unread.
|
|
132
|
+
kept = "?" + _percent(query, True) if query is not None and not re.match(r"[+=]", query) else ""
|
|
133
|
+
auth = "//" + _percent(authority, True) if authority is not None else ""
|
|
134
|
+
return "urn:%s%s%s" % (auth, path, kept)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _generic_form(scheme: str, authority: Optional[str], path: str, query: Optional[str], fragment: Optional[str]) -> str:
|
|
138
|
+
auth = ""
|
|
139
|
+
if authority is not None:
|
|
140
|
+
userinfo, host, port = _authority_parts(authority) # type: ignore[misc]
|
|
141
|
+
# The host is case-insensitive (§3.2.2); its remaining percent triplets keep upper-case hex.
|
|
142
|
+
lowered = re.sub(r"%[0-9a-f]{2}", lambda m: m.group(0).upper(), _percent(host).lower())
|
|
143
|
+
auth = "//%s%s%s" % ("" if userinfo is None else _percent(userinfo) + "@", lowered, "" if port is None else ":" + port)
|
|
144
|
+
decoded = _percent(path)
|
|
145
|
+
hierarchical = authority is not None or decoded.startswith("/")
|
|
146
|
+
out = _remove_dot_segments(decoded) if hierarchical else _strip_leading_dots(decoded)
|
|
147
|
+
if authority is None and out.startswith("//"): # it would read as an authority: keep the decoded path
|
|
148
|
+
out = decoded
|
|
149
|
+
q = "" if query is None else "?" + _percent(query)
|
|
150
|
+
f = "" if fragment is None else "#" + _percent(fragment)
|
|
151
|
+
return "%s:%s%s%s%s" % (scheme.lower(), auth, out, q, f)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def normal_identifier(value: Any) -> Optional[str]:
|
|
155
|
+
"""The normal form of an identifier: a URI's syntax-normalized form (RFC 3986 §6.2.2; RFC 8141 §3.1 for `urn:`),
|
|
156
|
+
any other string as it is, None for a value that is not a string. The contract's `normalIdentifier`."""
|
|
157
|
+
if not isinstance(value, str):
|
|
158
|
+
return None
|
|
159
|
+
parts = _parse(value)
|
|
160
|
+
if parts is None:
|
|
161
|
+
return value
|
|
162
|
+
scheme, authority, path, query, fragment = parts
|
|
163
|
+
return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _strict_equal(a: Any, b: Any) -> bool:
|
|
167
|
+
"""JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
|
|
168
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
169
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
170
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
171
|
+
return a == b
|
|
172
|
+
if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
|
|
173
|
+
return a is b
|
|
174
|
+
return type(a) is type(b) and a == b
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def same_identifier(a: Any, b: Any) -> bool:
|
|
178
|
+
"""Two identifiers are the same when their normal forms are equal (DEC-0076). A value that is not a string is no
|
|
179
|
+
identifier and is compared as it is (JavaScript's ===). The contract's `sameIdentifier`."""
|
|
180
|
+
if not isinstance(a, str) or not isinstance(b, str):
|
|
181
|
+
return _strict_equal(a, b)
|
|
182
|
+
return normal_identifier(a) == normal_identifier(b)
|