@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.82.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 +157 -0
- package/README.md +18 -5
- package/SECURITY.md +25 -8
- package/bin/fabric-agent-adapter.js +24 -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 +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
"""The source manifest's rules (DEC-0149, `fabric-source/1`, FAC-SEM-156 to FAC-SEM-160), ported from the contract's
|
|
2
|
+
`src/source-rules.ts` at the pin and held to its vectors (`test/fixtures/contract/source-vectors.json`, FAA-261).
|
|
3
|
+
|
|
4
|
+
- `source_tree_problems(value)`: the tree check over `{manifest, files, instanceStrings?, listed?}`, each place the
|
|
5
|
+
instance shows through as `{kind, file?, line?, column?, entry?, include?, exclude?}`, never the text it found;
|
|
6
|
+
- `patch_paths_allowed(manifest, paths)`: the paths of a patch that are not source (DEC-0149 item 13);
|
|
7
|
+
- `manifest_findings(manifest)`: FAC-SEM-156 (slots) and FAC-SEM-157 (paths), each `{code, instancePath, message}`;
|
|
8
|
+
- `package_findings(source, package)`: FAC-SEM-160, the package's `secrets[]` and `state[]` against the slots;
|
|
9
|
+
- `package_members(manifest)`: the `secrets[]` and `state[]` a packer writes from the manifest.
|
|
10
|
+
|
|
11
|
+
Columns count UTF-16 code units, as the contract's do; the credential and home-folder shapes are fabric_source_check's
|
|
12
|
+
(the contract ported them from it). Standard library only.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import unicodedata
|
|
22
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
23
|
+
from urllib.parse import unquote
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
26
|
+
import fabric_secrets as _secrets # noqa: E402 - the contract's credential detector (DEC-0044), beside this file
|
|
27
|
+
import fabric_source_check as _check # noqa: E402 - the credential and home-folder shapes this port shares
|
|
28
|
+
|
|
29
|
+
SOURCE_FILE = "fabric.source.json"
|
|
30
|
+
SOURCE_FORMAT = "fabric-source/1"
|
|
31
|
+
SLOT_KINDS = ("secret", "config", "store", "endpoint")
|
|
32
|
+
_GRAMMAR_HEAD = r"(?![/~])(?![\s\S]*\\)(?![\s\S]*//)(?!(?:[\s\S]*/)?\.{1,2}(?:/|\Z))"
|
|
33
|
+
SOURCE_FILE_PATH = re.compile(_GRAMMAR_HEAD + r"[^\u0000-\u001f:]{1,256}(?<!/)\Z")
|
|
34
|
+
SOURCE_PATH = re.compile(_GRAMMAR_HEAD + r"[^\u0000-\u001f:]{1,256}\Z")
|
|
35
|
+
_HFS_IGNORABLE = re.compile("[---]")
|
|
36
|
+
_NUSKHURI = re.compile("[ⴀ-ⴥ]")
|
|
37
|
+
_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
|
38
|
+
BLOCKING_KINDS = ("home-path", "credential", "binary", "nested-repository", "instance-string", "missing",
|
|
39
|
+
"short-instance-string", "no-include")
|
|
40
|
+
WARNING_KINDS = ("email", "link-outside", "no-license", "unused-exclude", "excluded-by-fold")
|
|
41
|
+
_JS_DOT = "[^\n\r
]"
|
|
42
|
+
CREDENTIAL_FILE = re.compile(r"(?:\.env(?:\.[^\n]+)?|[^\n]*\.(?:pem|key|p12|pfx|jks|keystore)|id_(?:rsa|dsa|ecdsa|ed25519)"
|
|
43
|
+
r"|\.(?:npmrc|netrc|pypirc))\Z")
|
|
44
|
+
CREDENTIAL_FILE_OK = re.compile(r"\.env\.(?:example|sample|template)\Z")
|
|
45
|
+
TOKEN_FILE = re.compile(r"[^\n]+\.(?:token|credential)\Z")
|
|
46
|
+
_SHORT_NAME = re.compile("~[0-9]")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _u16(text: str) -> int:
|
|
50
|
+
"""A text's length in UTF-16 code units, as the contract counts it."""
|
|
51
|
+
return len(text) + sum(1 for c in text if ord(c) > 0xFFFF)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _strings(value: Any) -> List[Tuple[str, int]]:
|
|
55
|
+
return [(item, index) for index, item in enumerate(value) if isinstance(item, str)] if isinstance(value, list) else []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _objects(value: Any) -> List[Tuple[Dict[str, Any], int]]:
|
|
59
|
+
return [(item, index) for index, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _nfc(text: str) -> str:
|
|
63
|
+
return unicodedata.normalize("NFC", text)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ascii_fold(text: str) -> str:
|
|
67
|
+
return text.translate(_ASCII_LOWER)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_dot_git_segment(segment: str) -> bool:
|
|
71
|
+
"""`.git` as Git's is_hfs_dotgit and is_ntfs_dotgit read a segment (or the short name `git~1`)."""
|
|
72
|
+
name = _ascii_fold(_HFS_IGNORABLE.sub("", segment)).rstrip(" .")
|
|
73
|
+
return name in (".git", "git~1")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _in_git(path: str) -> bool:
|
|
77
|
+
return any(is_dot_git_segment(s) for s in path.split("/"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fold_segment(segment: str) -> str:
|
|
81
|
+
"""One segment as a case-insensitive file system may resolve it: canonical caseless matching, Georgian Nuskhuri onto
|
|
82
|
+
Mkhedruli, trailing dots and spaces dropped (`foldSegment`)."""
|
|
83
|
+
text = unicodedata.normalize("NFD", _HFS_IGNORABLE.sub("", segment)).lower().upper().lower()
|
|
84
|
+
text = _NUSKHURI.sub(lambda m: chr(ord(m.group(0)) - 0x2D00 + 0x10D0), _nfc(text))
|
|
85
|
+
return text.rstrip(" .")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fold_path(path: str) -> str:
|
|
89
|
+
return "/".join(fold_segment(s) for s in path.split("/"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def in_grammar(path: str, folder: bool = False) -> bool:
|
|
93
|
+
"""In the grammar as written, in NFC and in NFD, with no segment that folds to nothing (`inGrammar`)."""
|
|
94
|
+
grammar = SOURCE_PATH if folder else SOURCE_FILE_PATH
|
|
95
|
+
if not all(grammar.match(p) for p in (path, _nfc(path), unicodedata.normalize("NFD", path))):
|
|
96
|
+
return False
|
|
97
|
+
body = path[:-1] if path.endswith("/") else path
|
|
98
|
+
return all(fold_segment(s) != "" for s in body.split("/"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _folder_prefixes(path: str) -> List[str]:
|
|
102
|
+
return [path[:at + 1] for at, c in enumerate(path) if c == "/"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _PathList:
|
|
106
|
+
def __init__(self, entries: List[str], reaches: bool = False, everything: bool = False) -> None:
|
|
107
|
+
self.files = {e for e in entries if not e.endswith("/")}
|
|
108
|
+
self.folders = {e for e in entries if e.endswith("/")}
|
|
109
|
+
self.reaches = {p for e in entries for p in _folder_prefixes(e)} if reaches else set()
|
|
110
|
+
self.all = everything
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _empty_segment(entry: str) -> Tuple[List[str], int, bool]:
|
|
114
|
+
written = _nfc(entry)
|
|
115
|
+
bare = not written.endswith("/")
|
|
116
|
+
segments = (written if bare else written[:-1]).split("/")
|
|
117
|
+
at = next((i for i, s in enumerate(segments) if fold_segment(s) == ""), -1)
|
|
118
|
+
return segments, at, bare
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _widest_reading(entry: str) -> Tuple[bool, bool, List[str]]:
|
|
122
|
+
"""An exclude entry outside the grammar as a file system reads it removes its widest reading (`widestReading`)."""
|
|
123
|
+
segments, at, bare = _empty_segment(entry)
|
|
124
|
+
if at < 0:
|
|
125
|
+
return False, bare, [fold_path(_nfc(entry))]
|
|
126
|
+
depth = shallowest = at
|
|
127
|
+
for segment in segments[at:]:
|
|
128
|
+
if fold_segment(segment) != "":
|
|
129
|
+
depth += 1
|
|
130
|
+
elif segment.count(".") >= 2:
|
|
131
|
+
depth -= 1
|
|
132
|
+
shallowest = min(shallowest, depth)
|
|
133
|
+
if shallowest <= 0:
|
|
134
|
+
return True, bare, []
|
|
135
|
+
folder = "/".join(fold_segment(s) for s in segments[:shallowest])
|
|
136
|
+
return False, bare, ([folder + "/", folder] if bare else [folder + "/"])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _folded_list(value: Any, widest: bool = False) -> _PathList:
|
|
140
|
+
entries: List[str] = []
|
|
141
|
+
everything = False
|
|
142
|
+
for entry, _ in _strings(value):
|
|
143
|
+
if in_grammar(entry, True):
|
|
144
|
+
entries.append(fold_path(_nfc(entry)))
|
|
145
|
+
elif widest and SOURCE_PATH.match(entry):
|
|
146
|
+
whole, _bare, more = _widest_reading(entry)
|
|
147
|
+
everything = everything or whole
|
|
148
|
+
entries += more
|
|
149
|
+
return _PathList(entries, everything=everything)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _path_list(value: Any, reaches: bool = False) -> _PathList:
|
|
153
|
+
return _PathList([_nfc(e) for e, _ in _strings(value) if in_grammar(e, True)], reaches)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _excludes_folder(lst: _PathList, folded: str) -> bool:
|
|
157
|
+
return lst.all or any(p in lst.folders or p[:-1] in lst.files for p in _folder_prefixes(folded))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _covers_nfc(lst: _PathList, path: str) -> bool:
|
|
161
|
+
return path in lst.files or any(f in lst.folders for f in _folder_prefixes(path))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _list_covers(lst: _PathList, written: str) -> bool:
|
|
165
|
+
return _u16(written) <= 512 and _covers_nfc(lst, _nfc(written))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _exclude_covers_nfc(lst: _PathList, path: str) -> bool:
|
|
169
|
+
return lst.all or path in lst.files or any(f in lst.folders or f[:-1] in lst.files for f in _folder_prefixes(path))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _folded_covers(lst: _PathList, written: str) -> bool:
|
|
173
|
+
return _u16(written) <= 512 and _exclude_covers_nfc(lst, fold_path(_nfc(written)))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def source_path_test(manifest: Any) -> Callable[[Any], bool]:
|
|
177
|
+
"""Whether a path is source: in the grammar, in no `.git`, the manifest itself or inside an include path and no
|
|
178
|
+
exclude path (exclude read folded)."""
|
|
179
|
+
source = manifest if isinstance(manifest, dict) else {}
|
|
180
|
+
include = _path_list(source.get("include"))
|
|
181
|
+
exclude = _folded_list(source.get("exclude"), True)
|
|
182
|
+
|
|
183
|
+
def test(path: Any) -> bool:
|
|
184
|
+
if not isinstance(path, str) or not in_grammar(path) or _in_git(path):
|
|
185
|
+
return False
|
|
186
|
+
if _nfc(path) == SOURCE_FILE:
|
|
187
|
+
return True
|
|
188
|
+
return _list_covers(include, path) and not _folded_covers(exclude, path)
|
|
189
|
+
return test
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fold_only_exclude_test(manifest: Any) -> Callable[[Any], int]:
|
|
193
|
+
"""The first exclude entry that names a path only once both are folded, or -1 (`foldOnlyExcludeTest`)."""
|
|
194
|
+
source = manifest if isinstance(manifest, dict) else {}
|
|
195
|
+
include = _path_list(source.get("include"))
|
|
196
|
+
literal = _path_list(source.get("exclude"))
|
|
197
|
+
first: Dict[str, int] = {}
|
|
198
|
+
for entry, at in _strings(source.get("exclude")):
|
|
199
|
+
if in_grammar(entry, True):
|
|
200
|
+
first.setdefault(fold_path(_nfc(entry)), at)
|
|
201
|
+
|
|
202
|
+
def test(path: Any) -> int:
|
|
203
|
+
if not isinstance(path, str) or not in_grammar(path) or _in_git(path) or _nfc(path) == SOURCE_FILE:
|
|
204
|
+
return -1
|
|
205
|
+
if not _list_covers(include, path) or (_u16(path) <= 512 and _exclude_covers_nfc(literal, _nfc(path))):
|
|
206
|
+
return -1
|
|
207
|
+
folded = fold_path(_nfc(path))
|
|
208
|
+
keys = [folded] + [k for f in _folder_prefixes(folded) for k in (f, f[:-1])]
|
|
209
|
+
found = [first[k] for k in keys if k in first]
|
|
210
|
+
return min(found) if found else -1
|
|
211
|
+
return test
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def excluded_folder_test(manifest: Any) -> Callable[[str], bool]:
|
|
215
|
+
"""Whether a walk skips a folder (no trailing `/`): an exclude names it or a folder holding it, and no include
|
|
216
|
+
path lies under it (`excludedFolderTest`)."""
|
|
217
|
+
source = manifest if isinstance(manifest, dict) else {}
|
|
218
|
+
exclude = _folded_list(source.get("exclude"), True)
|
|
219
|
+
under = {p for e, _ in _strings(source.get("include")) if in_grammar(e, True) for p in _folder_prefixes(_nfc(e))}
|
|
220
|
+
return lambda written: _excludes_folder(exclude, fold_path(_nfc(written)) + "/") and (_nfc(written) + "/") not in under
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _unchecked_paths(manifest: Any) -> List[Tuple[str, int]]:
|
|
224
|
+
if not isinstance(manifest, dict):
|
|
225
|
+
return []
|
|
226
|
+
return [(e["path"], i) for e, i in _objects(manifest.get("unchecked")) if isinstance(e.get("path"), str)]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def unchecked_test(manifest: Any) -> Callable[[Any], bool]:
|
|
230
|
+
is_source = source_path_test(manifest)
|
|
231
|
+
unchecked = _path_list([p for p, _ in _unchecked_paths(manifest)])
|
|
232
|
+
return lambda path: is_source(path) and _nfc(path) != SOURCE_FILE and _list_covers(unchecked, path)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def at_manifest_path(written: Any) -> bool:
|
|
236
|
+
return isinstance(written, str) and _nfc(written[:-1] if written.endswith("/") else written) == SOURCE_FILE
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def nested_test(manifest: Any) -> Callable[[Any], bool]:
|
|
240
|
+
"""Whether a nested repository at a path lies in the source (`nestedTest`)."""
|
|
241
|
+
source = manifest if isinstance(manifest, dict) else {}
|
|
242
|
+
include = _path_list(source.get("include"), True)
|
|
243
|
+
exclude = _folded_list(source.get("exclude"), True)
|
|
244
|
+
|
|
245
|
+
def test(written: Any) -> bool:
|
|
246
|
+
stored = written[:-1] if isinstance(written, str) and written.endswith("/") else written
|
|
247
|
+
if not isinstance(stored, str) or not in_grammar(stored) or _in_git(stored):
|
|
248
|
+
return False
|
|
249
|
+
if at_manifest_path(written):
|
|
250
|
+
return True
|
|
251
|
+
path = _nfc(stored)
|
|
252
|
+
folder = path + "/"
|
|
253
|
+
reached = path in include.files or folder in include.reaches or any(p in include.folders for p in _folder_prefixes(folder))
|
|
254
|
+
folded = fold_path(path)
|
|
255
|
+
return reached and folded not in exclude.files and not _excludes_folder(exclude, folded + "/")
|
|
256
|
+
return test
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def credential_file_name(name: str) -> str:
|
|
260
|
+
return _ascii_fold(_HFS_IGNORABLE.sub("", name)).rstrip(" .")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def credential_file(path: str) -> bool:
|
|
264
|
+
"""A file that is a credential by its name, read as a case-insensitive or Windows file system resolves it."""
|
|
265
|
+
written = path[path.rfind("/") + 1:]
|
|
266
|
+
name = credential_file_name(written)
|
|
267
|
+
return bool((CREDENTIAL_FILE.match(name) and not CREDENTIAL_FILE_OK.match(written)) or TOKEN_FILE.match(name))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _credential_file_folded(path: str) -> bool:
|
|
271
|
+
written = path[path.rfind("/") + 1:]
|
|
272
|
+
name = fold_segment(written)
|
|
273
|
+
return bool((CREDENTIAL_FILE.match(name) and not CREDENTIAL_FILE_OK.match(written)) or TOKEN_FILE.match(name))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def patch_paths_allowed(manifest: Any, paths: Any) -> List[str]:
|
|
277
|
+
"""DEC-0149 item 13: the paths of a patch that are not source, in the order given (empty: the patch applies)."""
|
|
278
|
+
is_source = source_path_test(manifest)
|
|
279
|
+
source = manifest if isinstance(manifest, dict) else {}
|
|
280
|
+
any_exclude = bool(_strings(source.get("exclude")))
|
|
281
|
+
|
|
282
|
+
def refused(path: Any) -> bool:
|
|
283
|
+
if not is_source(path):
|
|
284
|
+
return True
|
|
285
|
+
folded = fold_path(_nfc(path))
|
|
286
|
+
return (folded == SOURCE_FILE or credential_file(path) or _credential_file_folded(path)
|
|
287
|
+
or (any_exclude and any(_SHORT_NAME.search(s) for s in folded.split("/"))))
|
|
288
|
+
if not isinstance(paths, list):
|
|
289
|
+
return ["paths"]
|
|
290
|
+
return [(p if isinstance(p, str) else "paths[%d]" % i) for i, p in enumerate(paths) if refused(p)]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# #region credential and home shapes: the contract's credentialAt and homeAt, read with the source check's shapes
|
|
294
|
+
_PUBLIC_SIGNATURE = re.compile(r"(\"(?:sig|signature|signatures)\"[ \t]*:[ \t]*\")([A-Za-z0-9_-]{86})(\")", re.IGNORECASE)
|
|
295
|
+
_URL_IN_LINE = re.compile("[A-Za-z][A-Za-z0-9+.-]{0,63}://[^\t\n\x0b\x0c\r \x1c-\x1f\x85\xa0 - "
|
|
296
|
+
"
\"'<>`]+")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _secret_urls(line: str) -> List[Tuple[int, int]]:
|
|
300
|
+
return [m.span() for m in _URL_IN_LINE.finditer(line) if _check._url_secret(m.group(0))] if "://" in line else []
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _unescaped(line: str) -> Optional[Tuple[str, List[int]]]:
|
|
304
|
+
if "\\" not in line:
|
|
305
|
+
return None
|
|
306
|
+
text, origin = _check._unescaped_with_origin(line)
|
|
307
|
+
return (text, [a for a, _ in origin]) if text != line else None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _credential_in(written: str) -> int:
|
|
311
|
+
line = _PUBLIC_SIGNATURE.sub(lambda m: m.group(1) + " " * len(m.group(2)) + m.group(3), written) if '"' in written else written
|
|
312
|
+
found: List[int] = []
|
|
313
|
+
block = _check.PRIVATE_KEY.search(line)
|
|
314
|
+
if block:
|
|
315
|
+
found.append(block.start())
|
|
316
|
+
found += [m.start() for m in _check.JWK_PRIVATE.finditer(line) if _check._real(m.group(1))]
|
|
317
|
+
found += [a for a, b in _jwt_spans(line) if _check._real(line[a:b])]
|
|
318
|
+
found += [a for a, _ in _check._provider_keys(line)]
|
|
319
|
+
found += [m.start() for m in _check._overlapping(_secrets._BEARER_INSIDE, line) if _check._real(m.group(0))]
|
|
320
|
+
found += [a for a, _ in _secret_urls(line)][:1]
|
|
321
|
+
for m in (_check._assigned(line) if ":" in line or "=" in line else ()):
|
|
322
|
+
name, value = m.group(1), m.group(2)
|
|
323
|
+
if _check._credential(name, value, _check._quoted(line, m)) and _secrets._long_secret(unquote(value)) \
|
|
324
|
+
and _check._real(unquote(value)):
|
|
325
|
+
found.append(m.start(2))
|
|
326
|
+
return min(found) if found else -1
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _jwt_spans(text: str) -> List[Tuple[int, int]]:
|
|
330
|
+
"""`jwtSpans`: `JWT_SHAPE`'s leftmost, non-overlapping matches, read as the contract's one pass reads them."""
|
|
331
|
+
return _secrets.jwt_spans(text)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def credential_at(line: str) -> int:
|
|
335
|
+
"""The index where a line first shows a credential, as written or with its JSON escapes undone, or -1."""
|
|
336
|
+
written = _credential_in(line)
|
|
337
|
+
if written >= 0:
|
|
338
|
+
return written
|
|
339
|
+
unescaped = _unescaped(line)
|
|
340
|
+
if unescaped is None:
|
|
341
|
+
return -1
|
|
342
|
+
found = _credential_in(unescaped[0])
|
|
343
|
+
return -1 if found < 0 else unescaped[1][found]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _credential_url_spans(line: str) -> List[Tuple[int, int]]:
|
|
347
|
+
written = _secret_urls(line)
|
|
348
|
+
unescaped = _unescaped(line)
|
|
349
|
+
if written or unescaped is None:
|
|
350
|
+
return written
|
|
351
|
+
text, origin = unescaped
|
|
352
|
+
return [(origin[a], origin[b] if b < len(origin) else len(line)) for a, b in _secret_urls(text)]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _own_secret_value(text: str) -> bool:
|
|
356
|
+
return bool(_secrets.has_jwt(text) or _check.URL_SECRET_VALUE.search(text) or _secrets._long_secret(text))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def credential_shaped(text: str) -> bool:
|
|
360
|
+
return credential_at(text) >= 0 or _own_secret_value(text)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _prose_secret_value(text: str) -> bool:
|
|
364
|
+
return bool(_secrets.has_jwt(text) or any(True for _ in _check._provider_keys(text))
|
|
365
|
+
or _secrets._BEARER_INSIDE.search(text) or _secrets._long_secret(text))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def home_at(line: str) -> int:
|
|
369
|
+
found = [m.start(1) for pattern in _check.HOME_PATHS for m in pattern.finditer(line)
|
|
370
|
+
if len(m.group(1)) > 1 and m.group(1).lower() not in _check.NEUTRAL_HOMES]
|
|
371
|
+
return min(found) if found else -1
|
|
372
|
+
# #endregion
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _forms(entry: str) -> List["re.Pattern[str]"]:
|
|
376
|
+
"""An instance string as written and in NFC, each also percent-encoded, form-encoded, JSON-escaped, with `\\/`, and
|
|
377
|
+
with every character outside ASCII as `\\uXXXX`; matched without regard to case."""
|
|
378
|
+
out: List[str] = []
|
|
379
|
+
for written in (entry, _nfc(entry)):
|
|
380
|
+
js = json.dumps(written, ensure_ascii=False)[1:-1]
|
|
381
|
+
ascii_form = "".join(c if ord(c) < 0x80 else "".join("\\u%04x" % u for u in _units(c)) for c in js)
|
|
382
|
+
percent = "".join(c if re.match(r"[A-Za-z0-9\-_.!~*'()]", c) else "".join("%%%02X" % b for b in c.encode("utf-8"))
|
|
383
|
+
for c in written)
|
|
384
|
+
for form in (written, percent, percent.replace("%20", "+"), js, js.replace("/", "\\/"), ascii_form):
|
|
385
|
+
if form not in out:
|
|
386
|
+
out.append(form)
|
|
387
|
+
return [re.compile(re.escape(f), re.IGNORECASE) for f in out]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _units(c: str) -> List[int]:
|
|
391
|
+
data = c.encode("utf-16-be", "surrogatepass")
|
|
392
|
+
return [int.from_bytes(data[i:i + 2], "big") for i in range(0, len(data), 2)]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _first_of(patterns: List["re.Pattern[str]"], text: str) -> Tuple[int, str]:
|
|
396
|
+
for form in (text, _nfc(text)):
|
|
397
|
+
found = [m.start() for m in (p.search(form) for p in patterns) if m]
|
|
398
|
+
if found:
|
|
399
|
+
return min(found), form
|
|
400
|
+
return -1, text
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
_LOCAL_CHAR = re.compile(r"[A-Za-z0-9._%+-]")
|
|
404
|
+
_DOMAIN = re.compile(r"[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _addresses(line: str) -> List[Tuple[int, str]]:
|
|
408
|
+
found, floor = [], 0
|
|
409
|
+
at = line.find("@")
|
|
410
|
+
while at >= 0:
|
|
411
|
+
start = at
|
|
412
|
+
while start > floor and at - start < 64 and _LOCAL_CHAR.match(line[start - 1]):
|
|
413
|
+
start -= 1
|
|
414
|
+
if start != at:
|
|
415
|
+
domain = _DOMAIN.match(line, at + 1)
|
|
416
|
+
if domain:
|
|
417
|
+
floor = domain.end()
|
|
418
|
+
found.append((start, line[start:floor]))
|
|
419
|
+
at = line.find("@", at + 1)
|
|
420
|
+
return found
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _example_address(address: str) -> bool:
|
|
424
|
+
domain = address[address.rfind("@") + 1:].lower()
|
|
425
|
+
return (any(domain == n or domain.endswith("." + n) for n in ("example.com", "example.org", "example.net"))
|
|
426
|
+
or domain.endswith((".invalid", ".test", ".example")))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _image_name(text: str) -> bool:
|
|
430
|
+
return text[text.rfind(".") + 1:].lower() in ("png", "jpg", "jpeg", "gif", "svg", "webp", "ico")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _not_an_address(line: str, start: int, text: str) -> bool:
|
|
434
|
+
return line[start + len(text):start + len(text) + 1] == ":" or _image_name(text)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
_MARKDOWN = re.compile(r"\.(?:md|mdx|markdown)\Z", re.IGNORECASE | re.ASCII)
|
|
438
|
+
_JS_SPACE = "[" + _secrets.JS_SPACE + "]"
|
|
439
|
+
_LINK = re.compile(r"\]\(%s{0,64}<?([^)\]%s>]{1,2048})>?(?:%s{1,64}[^)\]]{0,2048})?\)"
|
|
440
|
+
% (_JS_SPACE, _secrets.JS_SPACE, _JS_SPACE))
|
|
441
|
+
_SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:")
|
|
442
|
+
_LICENSE_FILE = re.compile(r"(?:LICENSE|LICENCE|COPYING)(?:\.%s+)?\Z" % _JS_DOT, re.IGNORECASE | re.ASCII)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _decode_uri_component(raw: str) -> str:
|
|
446
|
+
"""`decodeURIComponent`, or the text as written when it would throw."""
|
|
447
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", raw):
|
|
448
|
+
return raw
|
|
449
|
+
try:
|
|
450
|
+
return unquote(raw, errors="strict")
|
|
451
|
+
except UnicodeDecodeError:
|
|
452
|
+
return raw
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _resolve(parts: List[str], raw: str) -> Optional[str]:
|
|
456
|
+
for segment in raw.split("/"):
|
|
457
|
+
if segment in ("", "."):
|
|
458
|
+
continue
|
|
459
|
+
if segment == "..":
|
|
460
|
+
if not parts:
|
|
461
|
+
return None
|
|
462
|
+
parts.pop()
|
|
463
|
+
else:
|
|
464
|
+
parts.append(segment)
|
|
465
|
+
return "/".join(parts)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _resolve_link(file: str, target: str) -> Optional[str]:
|
|
469
|
+
raw = _decode_uri_component(re.sub(r"[#?][\s\S]*\Z", "", target, count=1))
|
|
470
|
+
return _resolve([] if raw.startswith("/") else file.split("/")[:-1], raw)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def source_tree_problems(value: Any) -> List[Dict[str, Any]]:
|
|
474
|
+
"""DEC-0149 item 8, the tree check (`sourceTreeProblems`)."""
|
|
475
|
+
data = value if isinstance(value, dict) else {}
|
|
476
|
+
manifest = data.get("manifest") if isinstance(data.get("manifest"), dict) else {}
|
|
477
|
+
problems: List[Dict[str, Any]] = []
|
|
478
|
+
instance: List[Tuple[int, List["re.Pattern[str]"]]] = []
|
|
479
|
+
if isinstance(data.get("instanceStrings"), list):
|
|
480
|
+
for index, entry in enumerate(data["instanceStrings"]):
|
|
481
|
+
if not isinstance(entry, str) or len(entry) < 3 or any(0xD800 <= ord(c) <= 0xDFFF for c in entry):
|
|
482
|
+
problems.append({"kind": "short-instance-string", "entry": index})
|
|
483
|
+
else:
|
|
484
|
+
instance.append((index, _forms(entry)))
|
|
485
|
+
if not _strings(manifest.get("include")):
|
|
486
|
+
problems.append({"kind": "no-include"})
|
|
487
|
+
allowed = {a.lower() for a, _ in _strings(manifest.get("publicAddresses"))}
|
|
488
|
+
binaries = {_nfc(p) for p, _ in _strings(manifest.get("binaries"))}
|
|
489
|
+
is_source, is_nested, is_unchecked = source_path_test(manifest), nested_test(manifest), unchecked_test(manifest)
|
|
490
|
+
given = data.get("files") if isinstance(data.get("files"), list) else []
|
|
491
|
+
files = [(f, i) for i, f in enumerate(given) if isinstance(f, dict)
|
|
492
|
+
and (is_nested(f.get("path")) if f.get("nestedRepository") is True else is_source(f.get("path")))]
|
|
493
|
+
source_paths = [f["path"] for f, _ in files if f.get("nestedRepository") is not True]
|
|
494
|
+
fold_only = fold_only_exclude_test(manifest)
|
|
495
|
+
folded_out = []
|
|
496
|
+
for index, f in enumerate(given):
|
|
497
|
+
if isinstance(f, dict) and f.get("nestedRepository") is not True:
|
|
498
|
+
at = fold_only(f.get("path"))
|
|
499
|
+
if at >= 0:
|
|
500
|
+
folded_out.append({"kind": "excluded-by-fold", "file": index, "exclude": at})
|
|
501
|
+
source_folders = {"/".join(parts[:k + 1]) for p in source_paths for parts in [_nfc(p).split("/")[:-1]] for k in range(len(parts))}
|
|
502
|
+
|
|
503
|
+
def link_inside(target: str) -> bool:
|
|
504
|
+
return target == "" or is_source(target) or _nfc(target) in source_folders
|
|
505
|
+
|
|
506
|
+
for f, index in files:
|
|
507
|
+
path = f["path"]
|
|
508
|
+
binary = f.get("binary") is True and _nfc(path) != SOURCE_FILE
|
|
509
|
+
if home_at(path) >= 0:
|
|
510
|
+
problems.append({"kind": "home-path", "file": index})
|
|
511
|
+
if credential_file(path) or credential_at(path) >= 0:
|
|
512
|
+
problems.append({"kind": "credential", "file": index})
|
|
513
|
+
for entry, patterns in instance:
|
|
514
|
+
if _first_of(patterns, path)[0] >= 0:
|
|
515
|
+
problems.append({"kind": "instance-string", "file": index, "entry": entry})
|
|
516
|
+
if f.get("nestedRepository") is True:
|
|
517
|
+
problems.append({"kind": "nested-repository", "file": index})
|
|
518
|
+
continue
|
|
519
|
+
if is_unchecked(path):
|
|
520
|
+
continue
|
|
521
|
+
if binary:
|
|
522
|
+
if _nfc(path) not in binaries:
|
|
523
|
+
problems.append({"kind": "binary", "file": index})
|
|
524
|
+
continue
|
|
525
|
+
text = f["text"] if isinstance(f.get("text"), str) else ""
|
|
526
|
+
link_outside = False
|
|
527
|
+
if f.get("link") is True:
|
|
528
|
+
resolved = None if text.startswith("/") else _resolve(path.split("/")[:-1], text)
|
|
529
|
+
link_outside = resolved is None or not link_inside(resolved)
|
|
530
|
+
markdown = bool(_MARKDOWN.search(path))
|
|
531
|
+
for at, raw in enumerate(text.split("\n")):
|
|
532
|
+
line = raw[:-1] if raw.endswith("\r") else raw
|
|
533
|
+
|
|
534
|
+
def place(kind: str, column: int, within: str = line, **extra: Any) -> None:
|
|
535
|
+
problems.append(dict({"kind": kind, "file": index, "line": at + 1, "column": _u16(within[:column]) + 1}, **extra))
|
|
536
|
+
home = home_at(line)
|
|
537
|
+
if home >= 0:
|
|
538
|
+
place("home-path", home)
|
|
539
|
+
credential = credential_at(line)
|
|
540
|
+
if credential >= 0:
|
|
541
|
+
place("credential", credential)
|
|
542
|
+
for entry, patterns in instance:
|
|
543
|
+
found, form = _first_of(patterns, line)
|
|
544
|
+
if found >= 0:
|
|
545
|
+
place("instance-string", found, form, entry=entry)
|
|
546
|
+
urls = _credential_url_spans(line) if credential >= 0 else []
|
|
547
|
+
address = next((s for s, t in _addresses(line) if t.lower() not in allowed and not _example_address(t)
|
|
548
|
+
and not _not_an_address(line, s, t) and not any(a <= s < b for a, b in urls)), None)
|
|
549
|
+
if address is not None:
|
|
550
|
+
place("email", address)
|
|
551
|
+
if markdown:
|
|
552
|
+
outside = None
|
|
553
|
+
for m in _LINK.finditer(line):
|
|
554
|
+
target = m.group(1)
|
|
555
|
+
if target.startswith("#") or target.startswith("//") or _SCHEME.match(target):
|
|
556
|
+
continue
|
|
557
|
+
resolved = _resolve_link(path, target)
|
|
558
|
+
if resolved is None or not link_inside(resolved):
|
|
559
|
+
outside = m.start()
|
|
560
|
+
break
|
|
561
|
+
if outside is not None:
|
|
562
|
+
place("link-outside", outside)
|
|
563
|
+
if at == 0:
|
|
564
|
+
link_outside = False
|
|
565
|
+
if at == 0 and link_outside:
|
|
566
|
+
place("link-outside", 0)
|
|
567
|
+
problems += folded_out
|
|
568
|
+
present = {_nfc(p) for p in source_paths}
|
|
569
|
+
present_folders = {f for p in present for f in _folder_prefixes(p)}
|
|
570
|
+
for written, at in _strings(manifest.get("include")):
|
|
571
|
+
if not in_grammar(written, True):
|
|
572
|
+
continue
|
|
573
|
+
entry = _nfc(written)
|
|
574
|
+
if not (entry in present_folders if entry.endswith("/") else entry in present):
|
|
575
|
+
problems.append({"kind": "missing", "include": at})
|
|
576
|
+
if isinstance(data.get("listed"), list):
|
|
577
|
+
listed = [fold_path(_nfc(p)) for p, _ in _strings(data["listed"]) if _u16(p) <= 513]
|
|
578
|
+
listed_files = set(listed)
|
|
579
|
+
listed_folders = {f for p in listed for f in _folder_prefixes(p)}
|
|
580
|
+
naming = {p["exclude"] for p in folded_out}
|
|
581
|
+
for written, at in _strings(manifest.get("exclude")):
|
|
582
|
+
if not in_grammar(written, True):
|
|
583
|
+
continue
|
|
584
|
+
entry = fold_path(_nfc(written))
|
|
585
|
+
used = entry in listed_folders if entry.endswith("/") else (entry in listed_files or entry + "/" in listed_folders)
|
|
586
|
+
if at not in naming and not used:
|
|
587
|
+
problems.append({"kind": "unused-exclude", "exclude": at})
|
|
588
|
+
linked = {f["path"] for f, _ in files if f.get("link") is True}
|
|
589
|
+
if not any("/" not in p and _LICENSE_FILE.match(p) and p not in linked for p in source_paths):
|
|
590
|
+
problems.append({"kind": "no-license"})
|
|
591
|
+
return problems
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# #region the manifest and the package (FAC-SEM-156, FAC-SEM-157, FAC-SEM-160)
|
|
595
|
+
def _slot_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
596
|
+
findings: List[Dict[str, str]] = []
|
|
597
|
+
|
|
598
|
+
def flag(at: str, message: str) -> None:
|
|
599
|
+
findings.append({"code": "FAC-SEM-156", "instancePath": at, "message": message})
|
|
600
|
+
if isinstance(value.get("description"), str) and _prose_secret_value(value["description"]):
|
|
601
|
+
flag("/description", "a manifest never holds a value: this looks like a credential")
|
|
602
|
+
names: List[Any] = []
|
|
603
|
+
stores: List[Any] = []
|
|
604
|
+
for slot, index in _objects(value.get("slots")):
|
|
605
|
+
if slot.get("name") in names:
|
|
606
|
+
flag("/slots/%d/name" % index, "slots[%d] is declared twice" % index)
|
|
607
|
+
names.append(slot.get("name"))
|
|
608
|
+
for field in ("name", "purpose"):
|
|
609
|
+
text = slot.get(field)
|
|
610
|
+
if isinstance(text, str) and (_secrets.secret_value(text) or credential_shaped(text)
|
|
611
|
+
or credential_shaped(json.dumps(text, ensure_ascii=False))):
|
|
612
|
+
flag("/slots/%d/%s" % (index, field), "a slot is named, never filled: this looks like a credential")
|
|
613
|
+
if slot.get("kind") == "store" and isinstance(slot.get("path"), str):
|
|
614
|
+
if slot["path"] in stores:
|
|
615
|
+
flag("/slots/%d/path" % index, "slots[%d].path is declared twice" % index)
|
|
616
|
+
stores.append(slot["path"])
|
|
617
|
+
return findings
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _grammar_finding(member: str, path: str, index: int, at: str = "") -> List[Dict[str, str]]:
|
|
621
|
+
if not SOURCE_PATH.match(path) or in_grammar(path, True):
|
|
622
|
+
return []
|
|
623
|
+
effect = "names, reaches and removes nothing" if member != "exclude" else "still removes its widest reading"
|
|
624
|
+
return [{"code": "FAC-SEM-157", "instancePath": "%s/%s/%d" % (at, member, index),
|
|
625
|
+
"message": "%s[%d] is outside the path grammar as a file system reads it, so it %s" % (member, index, effect)}]
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _path_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
629
|
+
findings: List[Dict[str, str]] = []
|
|
630
|
+
|
|
631
|
+
def flag(at: str, message: str) -> None:
|
|
632
|
+
findings.append({"code": "FAC-SEM-157", "instancePath": at, "message": message})
|
|
633
|
+
include = _strings(value.get("include"))
|
|
634
|
+
for member in ("include", "exclude", "binaries"):
|
|
635
|
+
seen: Set[str] = set()
|
|
636
|
+
for path, index in _strings(value.get(member)):
|
|
637
|
+
if path in seen:
|
|
638
|
+
flag("/%s/%d" % (member, index), "%s[%d] is listed twice" % (member, index))
|
|
639
|
+
seen.add(path)
|
|
640
|
+
if _in_git(path):
|
|
641
|
+
flag("/%s/%d" % (member, index), "%s[%d] lies in .git, which is never source" % (member, index))
|
|
642
|
+
findings.extend(_grammar_finding(member, path, index))
|
|
643
|
+
includes = _path_list(value.get("include"))
|
|
644
|
+
folded_includes = _folded_list(value.get("include"))
|
|
645
|
+
excludes = _folded_list(value.get("exclude"), True)
|
|
646
|
+
is_source = source_path_test(value)
|
|
647
|
+
|
|
648
|
+
def prefixes_of(written: str) -> List[str]:
|
|
649
|
+
return _folder_prefixes(fold_path(_nfc(written))) if in_grammar(written, True) else []
|
|
650
|
+
widened = _folded_list([e for e, _ in _strings(value.get("exclude")) if not in_grammar(e, True)], True)
|
|
651
|
+
for written, index in include:
|
|
652
|
+
path = fold_path(_nfc(written))
|
|
653
|
+
dead = (excludes.all or any(f != path and (f in excludes.folders or f[:-1] in excludes.files) for f in prefixes_of(written))
|
|
654
|
+
or path in widened.files or any(f in widened.folders or f[:-1] in widened.files for f in prefixes_of(written)))
|
|
655
|
+
if dead:
|
|
656
|
+
flag("/include/%d" % index, "include[%d] lies inside an exclude path, so it names nothing" % index)
|
|
657
|
+
folded_include_paths = [fold_path(_nfc(p)) for p, _ in include if in_grammar(p, True)]
|
|
658
|
+
for path, index in _strings(value.get("exclude")):
|
|
659
|
+
if not in_grammar(path, True):
|
|
660
|
+
continue
|
|
661
|
+
folded = fold_path(_nfc(path))
|
|
662
|
+
if folded in folded_includes.files or folded in folded_includes.folders or folded + "/" in folded_includes.folders:
|
|
663
|
+
flag("/exclude/%d" % index, "exclude[%d] is an include path: it would remove it whole, so leave both out" % index)
|
|
664
|
+
elif not any(f in folded_includes.folders for f in prefixes_of(path)) and not any(
|
|
665
|
+
i.startswith(folded if folded.endswith("/") else folded + "/") for i in folded_include_paths):
|
|
666
|
+
flag("/exclude/%d" % index, "exclude[%d] lies inside no include folder, so it removes nothing" % index)
|
|
667
|
+
for path, index in _strings(value.get("binaries")):
|
|
668
|
+
if not in_grammar(path, True):
|
|
669
|
+
continue
|
|
670
|
+
if fold_path(_nfc(path)) == SOURCE_FILE:
|
|
671
|
+
flag("/binaries/%d" % index, "binaries[%d] names %s, which is always read as text" % (index, SOURCE_FILE))
|
|
672
|
+
continue
|
|
673
|
+
if not _in_git(path) and not is_source(path):
|
|
674
|
+
flag("/binaries/%d" % index, "binaries[%d] is not a file of the source" % index)
|
|
675
|
+
unchecked: Set[str] = set()
|
|
676
|
+
for path, index in _unchecked_paths(value):
|
|
677
|
+
if path in unchecked:
|
|
678
|
+
flag("/unchecked/%d/path" % index, "unchecked[%d] is listed twice" % index)
|
|
679
|
+
unchecked.add(path)
|
|
680
|
+
if not path.endswith("/") and in_grammar(path) and fold_path(_nfc(path)) == SOURCE_FILE:
|
|
681
|
+
flag("/unchecked/%d/path" % index, "unchecked[%d] names %s, which is always read" % (index, SOURCE_FILE))
|
|
682
|
+
continue
|
|
683
|
+
inside = (in_grammar(path, True) and not _in_git(_nfc(path)[:-1])
|
|
684
|
+
and any(p in includes.folders for p in _folder_prefixes(_nfc(path)))
|
|
685
|
+
and not _excludes_folder(excludes, fold_path(_nfc(path)))) if path.endswith("/") else is_source(path)
|
|
686
|
+
if not inside:
|
|
687
|
+
flag("/unchecked/%d/path" % index, "unchecked[%d] is not in the source" % index)
|
|
688
|
+
return findings
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def manifest_findings(manifest: Any) -> List[Dict[str, str]]:
|
|
692
|
+
"""FAC-SEM-156 and FAC-SEM-157 over a source manifest (the contract's `source-manifest` rules)."""
|
|
693
|
+
return _slot_findings(manifest) + _path_findings(manifest) if isinstance(manifest, dict) else []
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def package_findings(source: Any, package: Any) -> List[Dict[str, str]]:
|
|
697
|
+
"""FAC-SEM-160: the package's secrets[] are the source's secret slots, by name and optional; its state[] paths the
|
|
698
|
+
store slots' paths, when state[] holds any entry."""
|
|
699
|
+
if not isinstance(source, dict) or not isinstance(package, dict):
|
|
700
|
+
return []
|
|
701
|
+
findings: List[Dict[str, str]] = []
|
|
702
|
+
|
|
703
|
+
def flag(at: str, message: str) -> None:
|
|
704
|
+
findings.append({"code": "FAC-SEM-160", "instancePath": at, "message": message})
|
|
705
|
+
slots = [s for s, _ in _objects(source.get("slots"))]
|
|
706
|
+
secrets = {s["name"]: s.get("optional") is True for s in slots if s.get("kind") == "secret" and isinstance(s.get("name"), str)}
|
|
707
|
+
stores = {s["path"] for s in slots if s.get("kind") == "store" and isinstance(s.get("path"), str)}
|
|
708
|
+
packaged = _objects(package.get("secrets"))
|
|
709
|
+
names = {e.get("name") for e, _ in packaged}
|
|
710
|
+
for name in secrets:
|
|
711
|
+
if name not in names:
|
|
712
|
+
flag("/package/secrets", "the source's secret slot %s is not in the package's secrets[]" % name)
|
|
713
|
+
for entry, index in packaged:
|
|
714
|
+
name = entry.get("name")
|
|
715
|
+
if not isinstance(name, str) or name not in secrets:
|
|
716
|
+
flag("/package/secrets/%d/name" % index, "secrets[%d] is no secret slot of the source" % index)
|
|
717
|
+
elif secrets[name] != (entry.get("optional") is True):
|
|
718
|
+
flag("/package/secrets/%d/optional" % index, "secrets[%d] is %soptional in the source" % (index, "" if secrets[name] else "not "))
|
|
719
|
+
state = package.get("state")
|
|
720
|
+
if not isinstance(state, list) or not state:
|
|
721
|
+
return findings
|
|
722
|
+
paths = {e.get("path") for e, _ in _objects(state)}
|
|
723
|
+
for path in sorted(stores):
|
|
724
|
+
if path not in paths:
|
|
725
|
+
flag("/package/state", "a store slot's path of the source is not in the package's state[]")
|
|
726
|
+
for entry, index in _objects(state):
|
|
727
|
+
if not isinstance(entry.get("path"), str) or entry["path"] not in stores:
|
|
728
|
+
flag("/package/state/%d/path" % index, "state[%d] is no store slot of the source" % index)
|
|
729
|
+
return findings
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def package_members(manifest: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
733
|
+
"""The `secrets[]` and `state[]` a packer writes from a source manifest: each secret slot by name, purpose and
|
|
734
|
+
optional, each store slot's path with its purpose (DEC-0149, the package's FAC-SEM-160)."""
|
|
735
|
+
secrets, state = [], []
|
|
736
|
+
for slot, _ in _objects(manifest.get("slots")):
|
|
737
|
+
if slot.get("kind") == "secret" and isinstance(slot.get("name"), str):
|
|
738
|
+
entry = {"name": slot["name"], "purpose": slot.get("purpose")}
|
|
739
|
+
if slot.get("optional") is True:
|
|
740
|
+
entry["optional"] = True
|
|
741
|
+
secrets.append(entry)
|
|
742
|
+
elif slot.get("kind") == "store" and isinstance(slot.get("path"), str):
|
|
743
|
+
state.append({"path": slot["path"], "purpose": slot.get("purpose")})
|
|
744
|
+
return secrets, state
|
|
745
|
+
# #endregion
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# #region the folder check: `fabric_source_check.py check` on a tree whose root holds fabric.source.json
|
|
749
|
+
MESSAGES = {
|
|
750
|
+
"home-path": "a path in a person's home folder belongs to the instance, never to the source",
|
|
751
|
+
"credential": "shaped like a credential: a key reaches the agent through its slot, never through the source",
|
|
752
|
+
"binary": "a binary file the manifest does not list in binaries: it is source only when listed",
|
|
753
|
+
"nested-repository": "a nested repository: name it in exclude and check it in its own tree",
|
|
754
|
+
"instance-string": "a string of the owner's list: the installation's own strings never travel",
|
|
755
|
+
"missing": "an include path names no file of the tree",
|
|
756
|
+
"short-instance-string": "an instance string shorter than 3 characters is not looked for",
|
|
757
|
+
"no-include": "the source manifest names no include path, so nothing is source",
|
|
758
|
+
"email": "an e-mail address not listed in publicAddresses: a person's address belongs to the instance",
|
|
759
|
+
"link-outside": "a link to a path outside the source: a source export does not carry its target",
|
|
760
|
+
"no-license": "no licence file at the source's root (LICENSE, LICENCE or COPYING)",
|
|
761
|
+
"unused-exclude": "an exclude path names nothing the tree holds, in any letter case: check its spelling",
|
|
762
|
+
"excluded-by-fold": "an exclude path names this file only in another spelling: it is left out and never read",
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def read_manifest(root: Path) -> Dict[str, Any]:
|
|
767
|
+
"""The source manifest at a tree's root, refused (CheckError, exit 2) when it is no `fabric-source/1` object."""
|
|
768
|
+
path = root / SOURCE_FILE
|
|
769
|
+
if path.is_dir():
|
|
770
|
+
raise _check.CheckError("%s is a folder, not a file: the manifest cannot be read" % SOURCE_FILE)
|
|
771
|
+
try:
|
|
772
|
+
value = json.loads(path.read_bytes().decode("utf-8"))
|
|
773
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
774
|
+
raise _check.CheckError("%s cannot be read as UTF-8 JSON" % SOURCE_FILE)
|
|
775
|
+
if not isinstance(value, dict) or value.get("format") != SOURCE_FORMAT:
|
|
776
|
+
raise _check.CheckError("%s is not a %s manifest (its format member)" % (SOURCE_FILE, SOURCE_FORMAT))
|
|
777
|
+
return value
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _nested_folders(root: Path, paths: List[str]) -> Set[str]:
|
|
781
|
+
"""In a walk, the folders below the root that hold their own `.git`: each a nested repository."""
|
|
782
|
+
folders = {p.rsplit("/", 1)[0] for p in paths if "/" in p}
|
|
783
|
+
found = set()
|
|
784
|
+
for folder in sorted({f for p in folders for f in ["/".join(p.split("/")[:k + 1]) for k in range(p.count("/") + 1)]}):
|
|
785
|
+
if not any(folder.startswith(n + "/") for n in found) and (root / folder / ".git").exists():
|
|
786
|
+
found.add(folder)
|
|
787
|
+
return found
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def check_folder(root: Path, listing: List[str], mode: str, entries: List[str], unreadable: List[str],
|
|
791
|
+
dependencies: List[str]) -> Dict[str, Any]:
|
|
792
|
+
"""The tree check over the files a folder holds, under its fabric.source.json: findings with a column and a
|
|
793
|
+
severity, every path printed as `redact` prints it, never a value found."""
|
|
794
|
+
manifest = read_manifest(root)
|
|
795
|
+
seen = _check._Seen()
|
|
796
|
+
strings = [(n, e) for n, e in enumerate(entries, 1) if e and not e.startswith(_check.HOME_ENTRY)]
|
|
797
|
+
owner_homes = _check._owner_homes(entries)
|
|
798
|
+
nested = _nested_folders(root, listing) if mode == "walk" else set()
|
|
799
|
+
files: List[Dict[str, Any]] = []
|
|
800
|
+
not_read: List[Dict[str, str]] = [{"path": p, "why": "unreadable"} for p in unreadable]
|
|
801
|
+
is_source, unchecked = source_path_test(manifest), unchecked_test(manifest)
|
|
802
|
+
listed: List[str] = []
|
|
803
|
+
for folder in sorted(nested):
|
|
804
|
+
files.append({"path": folder, "nestedRepository": True})
|
|
805
|
+
listed.append(folder)
|
|
806
|
+
for path in listing:
|
|
807
|
+
if any(path.startswith(n + "/") for n in nested):
|
|
808
|
+
continue
|
|
809
|
+
listed.append(path)
|
|
810
|
+
full = root / path
|
|
811
|
+
entry: Dict[str, Any] = {"path": path}
|
|
812
|
+
try:
|
|
813
|
+
if full.is_symlink():
|
|
814
|
+
entry.update(link=True, text=os.readlink(str(full)))
|
|
815
|
+
elif full.is_dir():
|
|
816
|
+
entry["nestedRepository"] = True
|
|
817
|
+
listed.append(path + "/")
|
|
818
|
+
elif is_source(path):
|
|
819
|
+
data = full.read_bytes()
|
|
820
|
+
if b"\0" in data[:8192] and _nfc(path) != SOURCE_FILE:
|
|
821
|
+
entry["binary"] = True
|
|
822
|
+
else:
|
|
823
|
+
entry["text"] = data.decode("utf-8", "replace")
|
|
824
|
+
except OSError:
|
|
825
|
+
if is_source(path):
|
|
826
|
+
not_read.append({"path": path, "why": "unreadable"})
|
|
827
|
+
continue
|
|
828
|
+
files.append(entry)
|
|
829
|
+
problems = source_tree_problems({"manifest": manifest, "files": files, "listed": listed,
|
|
830
|
+
"instanceStrings": [e for _, e in strings]})
|
|
831
|
+
findings: List[Dict[str, Any]] = []
|
|
832
|
+
for f in manifest_findings(manifest):
|
|
833
|
+
findings.append({"path": SOURCE_FILE, "line": 0, "column": 0, "kind": "manifest", "severity": "blocking",
|
|
834
|
+
"rule": "%s at %s: %s" % (f["code"], f["instancePath"], f["message"])})
|
|
835
|
+
for p in problems:
|
|
836
|
+
path = files[p["file"]]["path"] if "file" in p else SOURCE_FILE
|
|
837
|
+
rule = MESSAGES[p["kind"]]
|
|
838
|
+
if "entry" in p:
|
|
839
|
+
rule += " (line %d of the owner's list)" % strings[p["entry"]][0]
|
|
840
|
+
for member in ("include", "exclude"):
|
|
841
|
+
if member in p:
|
|
842
|
+
rule += " (%s[%d])" % (member, p[member])
|
|
843
|
+
if "line" in p:
|
|
844
|
+
text = files[p["file"]].get("text", "").split("\n")[p["line"] - 1]
|
|
845
|
+
_check._text_findings(text.rstrip("\r"), entries, seen)
|
|
846
|
+
elif "file" in p:
|
|
847
|
+
_check._text_findings(path, entries, seen)
|
|
848
|
+
findings.append({"path": path, "line": p.get("line", 0), "column": p.get("column", 0), "kind": p["kind"],
|
|
849
|
+
"severity": "blocking" if p["kind"] in BLOCKING_KINDS else "warning", "rule": rule})
|
|
850
|
+
if owner_homes: # a declared home (`home: <name>`) is the owner's in every form, neutral or one letter
|
|
851
|
+
found = {(f["path"], f["line"]) for f in findings if f["kind"] == "home-path"}
|
|
852
|
+
for entry in files:
|
|
853
|
+
if "text" not in entry or not is_source(entry["path"]) or unchecked(entry["path"]):
|
|
854
|
+
continue
|
|
855
|
+
for number, line in enumerate(entry["text"].split("\n"), 1):
|
|
856
|
+
line = line[:-1] if line.endswith("\r") else line
|
|
857
|
+
spans = [a for a, b in _check._home_names(line, owner_homes) if line[a:b].lower() in owner_homes]
|
|
858
|
+
if spans and (entry["path"], number) not in found:
|
|
859
|
+
_check._text_findings(line, entries, seen)
|
|
860
|
+
findings.append({"path": entry["path"], "line": number, "column": _u16(line[:min(spans)]) + 1,
|
|
861
|
+
"kind": "home-path", "severity": "blocking", "rule": MESSAGES["home-path"]})
|
|
862
|
+
shown: Dict[str, str] = {} # one redaction per path, however many findings it has
|
|
863
|
+
for item in findings + not_read:
|
|
864
|
+
if item["path"] not in shown:
|
|
865
|
+
shown[item["path"]] = _check.redact(item["path"], entries, seen)
|
|
866
|
+
item["path"] = shown[item["path"]]
|
|
867
|
+
read = sum(1 for e in files if "text" in e and is_source(e["path"]))
|
|
868
|
+
source = sum(1 for e in files if e.get("nestedRepository") is not True and is_source(e["path"]))
|
|
869
|
+
return {"mode": mode, "manifest": True, "files": read, "source": source,
|
|
870
|
+
"skipped": {"binary": 0, "unreadable": len(not_read), "excluded": len(listing) - source, "nested": 0,
|
|
871
|
+
"dependencies": len(dependencies)},
|
|
872
|
+
"notRead": not_read, "findings": findings, "dependencies": [_check.redact(d, entries, seen) for d in dependencies]}
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def print_run(result: Dict[str, Any], entries: List[str], listed_any: bool, as_json: bool, _print: Callable[[str], None]) -> int:
|
|
876
|
+
"""The run under a source manifest: exit 1 on a blocking finding, 0 when only warnings remain and every source
|
|
877
|
+
file was read, 2 when one was not."""
|
|
878
|
+
findings = result["findings"]
|
|
879
|
+
blocking = [f for f in findings if f["severity"] == "blocking"] # type: ignore[union-attr,index]
|
|
880
|
+
complete = not result["notRead"]
|
|
881
|
+
result.update({"instanceStrings": sum(1 for e in entries if e and not e.startswith(_check.HOME_ENTRY)),
|
|
882
|
+
"ownerHomes": sum(1 for e in entries if e.startswith(_check.HOME_ENTRY)), "instanceList": listed_any,
|
|
883
|
+
"blocking": bool(blocking), "complete": complete})
|
|
884
|
+
if as_json:
|
|
885
|
+
_print(json.dumps(result, indent=2))
|
|
886
|
+
else:
|
|
887
|
+
for f in findings: # type: ignore[union-attr]
|
|
888
|
+
_print("%s:%s:%s: %s%s (%s)" % (f["path"], f["line"], f["column"], "" if f["severity"] == "blocking"
|
|
889
|
+
else "warning: ", f["kind"], f["rule"]))
|
|
890
|
+
for n in result["notRead"]: # type: ignore[union-attr]
|
|
891
|
+
_print("%s: not read (%s)" % (n["path"], n["why"]))
|
|
892
|
+
_print("fabric.source.json decides the source: %d source file(s), %d read as text (%s), %d blocking finding(s), "
|
|
893
|
+
"%d warning(s); %d path(s) not source; .fabric-source-ignore and --exclude are not read under a manifest"
|
|
894
|
+
% (result["source"], result["files"], "what Git would commit" if result["mode"] == "git" else "a walk",
|
|
895
|
+
len(blocking), len(findings) - len(blocking), result["skipped"]["excluded"])) # type: ignore
|
|
896
|
+
if blocking:
|
|
897
|
+
return 1
|
|
898
|
+
return 0 if complete else 2
|
|
899
|
+
# #endregion
|