workpeg 0.78.0__tar.gz → 0.79.1__tar.gz
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.
- {workpeg-0.78.0/src/workpeg.egg-info → workpeg-0.79.1}/PKG-INFO +1 -1
- {workpeg-0.78.0 → workpeg-0.79.1}/pyproject.toml +1 -1
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/cdn.py +10 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/cli.py +21 -6
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/context.py +21 -2
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/run.py +4 -1
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/submit.py +40 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/compiler/ast_parser.py +6 -5
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/fake_cloud.py +39 -3
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/runtime/ai.js +43 -3
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/runtime/workpeg-runtime.js +14 -4
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/serve.py +48 -3
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/interactive.py +3 -0
- {workpeg-0.78.0 → workpeg-0.79.1/src/workpeg.egg-info}/PKG-INFO +1 -1
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_cdn.py +31 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_context.py +80 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_run.py +3 -3
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_submit.py +62 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/LICENSE +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/MANIFEST.in +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/README.md +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/setup.cfg +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/build.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/config.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/create_new.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/runtime.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/Dockerfile +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/LICENSE +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/README.md +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/app/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/app/main.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/requirements.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/actions.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/app.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/build.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/compiler/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/compiler/ir.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/compiler/js_generator.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/create.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/minify.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/runtime/pegment.css +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/scaffold.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/state.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/validators.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/watcher.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/base.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/canvas.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/display.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/forms.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/futures.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/gestures.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/layout.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/utils.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/SOURCES.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/dependency_links.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/entry_points.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/requires.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/top_level.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_build.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_changelog.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_changelog_guard.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_cli.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_config.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_create_new.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_release_script.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_runtime.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_utils.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_watch_pipeline.py +0 -0
|
@@ -9,6 +9,8 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
import requests
|
|
11
11
|
|
|
12
|
+
from workpeg.submit import _looks_secret
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
DEFAULT_API_BASE = "https://repo.workpeg.com"
|
|
14
16
|
|
|
@@ -21,6 +23,10 @@ EXCLUDED_DIRS = {
|
|
|
21
23
|
"venv",
|
|
22
24
|
"dist",
|
|
23
25
|
"build",
|
|
26
|
+
# credential stores — never publish these
|
|
27
|
+
".ssh",
|
|
28
|
+
".aws",
|
|
29
|
+
".gnupg",
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
EXCLUDED_SUFFIXES = (".pyc", ".pyo")
|
|
@@ -79,6 +85,10 @@ def build_directory_tar_gz_bytes(path: Path) -> bytes:
|
|
|
79
85
|
if item.name.endswith(EXCLUDED_SUFFIXES):
|
|
80
86
|
continue
|
|
81
87
|
|
|
88
|
+
# Never publish secrets, and never follow a symlink out of the tree.
|
|
89
|
+
if item.is_symlink() or _looks_secret(item.name):
|
|
90
|
+
continue
|
|
91
|
+
|
|
82
92
|
archive.add(item, arcname=str(rel))
|
|
83
93
|
|
|
84
94
|
return out.getvalue()
|
|
@@ -388,6 +388,18 @@ def _fake_cloud_setup(app_name: str, explicit_port: int) -> "tuple[int, dict]":
|
|
|
388
388
|
return port, {"ssl_cert": cert, "ssl_key": key, "url_host": domain}
|
|
389
389
|
|
|
390
390
|
|
|
391
|
+
def _run_server(serve_fn, *args, **kwargs) -> None:
|
|
392
|
+
"""Run a blocking dev server, turning a ServeError (e.g. a privileged or
|
|
393
|
+
in-use port) into a clean CLI error instead of a raw traceback."""
|
|
394
|
+
from workpeg.ui.serve import ServeError
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
serve_fn(*args, **kwargs)
|
|
398
|
+
except ServeError as e:
|
|
399
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
400
|
+
raise SystemExit(1)
|
|
401
|
+
|
|
402
|
+
|
|
391
403
|
def main(argv=None) -> None:
|
|
392
404
|
argv = argv if argv is not None else sys.argv[1:]
|
|
393
405
|
|
|
@@ -597,7 +609,8 @@ def main(argv=None) -> None:
|
|
|
597
609
|
|
|
598
610
|
# Watch the whole project directory so edits to any page file trigger
|
|
599
611
|
# a recompile — not just changes to main.py.
|
|
600
|
-
|
|
612
|
+
_run_server(
|
|
613
|
+
serve_blocking,
|
|
601
614
|
str(project_dir / "dist"),
|
|
602
615
|
port,
|
|
603
616
|
watch_source=str(project_dir),
|
|
@@ -653,8 +666,9 @@ def main(argv=None) -> None:
|
|
|
653
666
|
|
|
654
667
|
# Watch the project directory, not just main.py, so edits to
|
|
655
668
|
# any page file also trigger a recompile.
|
|
656
|
-
serve_blocking
|
|
657
|
-
|
|
669
|
+
_run_server(serve_blocking, out_dir, port,
|
|
670
|
+
watch_source=str(source),
|
|
671
|
+
peg_namespace=peg_ns, **extra)
|
|
658
672
|
else:
|
|
659
673
|
# No main.py — treat as a pre-built dist directory and serve
|
|
660
674
|
# as-is. There's no recompile, so --peg-namespace can't apply
|
|
@@ -662,7 +676,7 @@ def main(argv=None) -> None:
|
|
|
662
676
|
if peg_ns:
|
|
663
677
|
print(" Note: --peg-namespace is ignored for a pre-built "
|
|
664
678
|
"dist (rebuild to change it).", file=sys.stderr)
|
|
665
|
-
serve_blocking
|
|
679
|
+
_run_server(serve_blocking, str(source), port, **extra)
|
|
666
680
|
|
|
667
681
|
elif source.suffix == ".py":
|
|
668
682
|
from workpeg.ui.build import build_ui
|
|
@@ -676,8 +690,9 @@ def main(argv=None) -> None:
|
|
|
676
690
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
677
691
|
raise SystemExit(2)
|
|
678
692
|
|
|
679
|
-
serve_blocking
|
|
680
|
-
|
|
693
|
+
_run_server(serve_blocking, out_dir, port,
|
|
694
|
+
watch_source=str(source),
|
|
695
|
+
peg_namespace=peg_ns, **extra)
|
|
681
696
|
|
|
682
697
|
else:
|
|
683
698
|
print(
|
|
@@ -62,7 +62,16 @@ class _TableContext(BaseContext):
|
|
|
62
62
|
|
|
63
63
|
@classmethod
|
|
64
64
|
def _verify_columns(cls, columns):
|
|
65
|
-
|
|
65
|
+
# A structural check, not a full schema: columns is a list of column
|
|
66
|
+
# definitions (each an object). Defense-in-depth on data the platform
|
|
67
|
+
# serialises in — reject a clearly-wrong shape, don't second-guess
|
|
68
|
+
# each field.
|
|
69
|
+
if columns is None:
|
|
70
|
+
return
|
|
71
|
+
if not isinstance(columns, list):
|
|
72
|
+
raise ValueError("Columns must be a list of column definitions.")
|
|
73
|
+
if not all(isinstance(c, dict) for c in columns):
|
|
74
|
+
raise ValueError("Each column must be a dictionary.")
|
|
66
75
|
|
|
67
76
|
@classmethod
|
|
68
77
|
def _verify_namespace(cls, namespace):
|
|
@@ -71,7 +80,17 @@ class _TableContext(BaseContext):
|
|
|
71
80
|
|
|
72
81
|
@classmethod
|
|
73
82
|
def _verify_peg(cls, peg):
|
|
74
|
-
|
|
83
|
+
# A peg identifier: a positive integer id (the common case) or a
|
|
84
|
+
# namespace string. bool is an int subclass in Python, so exclude it
|
|
85
|
+
# explicitly.
|
|
86
|
+
if peg is None:
|
|
87
|
+
return
|
|
88
|
+
if isinstance(peg, bool) or not isinstance(peg, (int, str)):
|
|
89
|
+
raise ValueError("Peg must be an integer id, a string, or None.")
|
|
90
|
+
if isinstance(peg, int) and peg <= 0:
|
|
91
|
+
raise ValueError("Peg id must be a positive integer.")
|
|
92
|
+
if isinstance(peg, str) and not peg.strip():
|
|
93
|
+
raise ValueError("Peg string must be non-empty.")
|
|
75
94
|
|
|
76
95
|
|
|
77
96
|
class _StaffContext(BaseContext):
|
|
@@ -44,7 +44,10 @@ def run_with_docker(
|
|
|
44
44
|
cmd += ["--name", name]
|
|
45
45
|
|
|
46
46
|
if expose:
|
|
47
|
-
|
|
47
|
+
# Publish to LOOPBACK only — a locally-run function's /invoke shouldn't
|
|
48
|
+
# be reachable from the LAN. (The deployed runtime binds 0.0.0.0 inside
|
|
49
|
+
# its own managed container; this is the developer's machine.)
|
|
50
|
+
cmd += ["-p", f"127.0.0.1:{port}:{port}"]
|
|
48
51
|
|
|
49
52
|
if network:
|
|
50
53
|
cmd += ["--network", network]
|
|
@@ -16,6 +16,26 @@ JWT_ENV = "WORKPEG_PK"
|
|
|
16
16
|
API_ENV = "WORKPEG_API_BASE"
|
|
17
17
|
PATH_ENV = "WORKPEG_SUBMIT_PATH"
|
|
18
18
|
|
|
19
|
+
# Files that must never leave the machine in a bundle/upload — env files, key
|
|
20
|
+
# material, and credential stores. Matched by basename (case-insensitively).
|
|
21
|
+
_SECRET_NAMES = frozenset({
|
|
22
|
+
".envrc", ".netrc", ".npmrc", ".pypirc", ".htpasswd", ".dockercfg",
|
|
23
|
+
"credentials", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
|
|
24
|
+
})
|
|
25
|
+
_SECRET_SUFFIXES = (".pem", ".key", ".pfx", ".p12", ".keystore", ".jks")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _looks_secret(name: str) -> bool:
|
|
29
|
+
"""A basename that looks like a secret (``.env``/``.env.local``, a key file,
|
|
30
|
+
a credential store) — excluded from bundles and uploads."""
|
|
31
|
+
lo = name.lower()
|
|
32
|
+
return (
|
|
33
|
+
lo == ".env"
|
|
34
|
+
or lo.startswith(".env.")
|
|
35
|
+
or lo in _SECRET_NAMES
|
|
36
|
+
or lo.endswith(_SECRET_SUFFIXES)
|
|
37
|
+
)
|
|
38
|
+
|
|
19
39
|
|
|
20
40
|
def _fail(msg: str, code: int = 2) -> None:
|
|
21
41
|
print(f"ERROR: {msg}", file=sys.stderr)
|
|
@@ -91,8 +111,13 @@ def build_tar_gz_bytes(source_dir: Path) -> bytes:
|
|
|
91
111
|
"dist",
|
|
92
112
|
"build",
|
|
93
113
|
".DS_Store",
|
|
114
|
+
# credential stores — never ship these off the machine
|
|
115
|
+
".ssh",
|
|
116
|
+
".aws",
|
|
117
|
+
".gnupg",
|
|
94
118
|
}
|
|
95
119
|
|
|
120
|
+
skipped_secrets: list[str] = []
|
|
96
121
|
buf = io.BytesIO()
|
|
97
122
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
98
123
|
for p in source_dir.rglob("*"):
|
|
@@ -101,12 +126,27 @@ def build_tar_gz_bytes(source_dir: Path) -> bytes:
|
|
|
101
126
|
if any(part in exclude_names for part in rel.parts):
|
|
102
127
|
continue
|
|
103
128
|
|
|
129
|
+
# Never bundle secrets, and never follow symlinks out of the project
|
|
130
|
+
# (a link could point at anything on the machine). Belt-and-braces on
|
|
131
|
+
# top of validate_function_project.
|
|
132
|
+
if p.is_symlink():
|
|
133
|
+
skipped_secrets.append(rel.as_posix() + " (symlink)")
|
|
134
|
+
continue
|
|
135
|
+
if _looks_secret(rel.name):
|
|
136
|
+
skipped_secrets.append(rel.as_posix())
|
|
137
|
+
continue
|
|
138
|
+
|
|
104
139
|
if not p.exists():
|
|
105
140
|
continue
|
|
106
141
|
|
|
107
142
|
arcname = rel.as_posix()
|
|
108
143
|
tar.add(p.as_posix(), arcname=arcname, recursive=False)
|
|
109
144
|
|
|
145
|
+
if skipped_secrets:
|
|
146
|
+
print(
|
|
147
|
+
" Skipped (looks like a secret / symlink, not bundled): "
|
|
148
|
+
+ ", ".join(sorted(skipped_secrets))
|
|
149
|
+
)
|
|
110
150
|
return buf.getvalue()
|
|
111
151
|
|
|
112
152
|
|
|
@@ -76,10 +76,11 @@ _WORKPEG_TABLE_HELPERS = {
|
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def _is_workpeg_host(url: str) -> bool:
|
|
79
|
-
"""True when *url* targets a Workpeg-owned host — ``workpeg``
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
"""True when *url* targets a Workpeg-owned host — ``workpeg`` as the
|
|
80
|
+
REGISTRABLE label, the one right before the TLD (workpeg.com, *.workpeg.io,
|
|
81
|
+
…), not merely somewhere in the name (so ``foo.workpeg.evil.com`` is NOT
|
|
82
|
+
Workpeg). Relative URLs have no host and are never Workpeg. Mirrors
|
|
83
|
+
``Workpeg._isWorkpegHost`` in the runtime."""
|
|
83
84
|
try:
|
|
84
85
|
host = urlparse(url).hostname
|
|
85
86
|
except ValueError:
|
|
@@ -87,7 +88,7 @@ def _is_workpeg_host(url: str) -> bool:
|
|
|
87
88
|
if not host:
|
|
88
89
|
return False
|
|
89
90
|
labels = host.lower().split(".")
|
|
90
|
-
return
|
|
91
|
+
return len(labels) >= 2 and labels[-2] == "workpeg"
|
|
91
92
|
|
|
92
93
|
|
|
93
94
|
_SUPPORTED_WIDGETS = {
|
|
@@ -47,6 +47,30 @@ def slugify(name: str) -> str:
|
|
|
47
47
|
return re.sub(r"[^a-z0-9-]", "", slug).strip("-")
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
# A plain hostname: dot-separated LDH labels, ≤253 chars. Anything else — a
|
|
51
|
+
# space, newline, slash, shell metachar — is rejected before it reaches a hosts
|
|
52
|
+
# file (where a newline could inject another mapping), a cert subject, or a path.
|
|
53
|
+
_VALID_DOMAIN = re.compile(
|
|
54
|
+
r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
|
55
|
+
r"(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _require_valid_domain(domain: str) -> None:
|
|
61
|
+
if not isinstance(domain, str) or not _VALID_DOMAIN.match(domain):
|
|
62
|
+
raise FakeCloudError(f"Refusing to use an invalid hostname: {domain!r}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _lock_down_key(key: "Path") -> None:
|
|
66
|
+
"""Make a generated TLS private key owner read/write only (0600). A no-op
|
|
67
|
+
where POSIX permissions don't apply."""
|
|
68
|
+
try:
|
|
69
|
+
os.chmod(key, 0o600)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
50
74
|
def derive_domain(name: str) -> str:
|
|
51
75
|
"""Turn a project name into ``dev.<slug>.workpeg.com``.
|
|
52
76
|
|
|
@@ -107,6 +131,7 @@ def ensure_hosts_entry(domain: str, hosts_path: "str | None" = None) -> bool:
|
|
|
107
131
|
terminal. Entries are tagged with a marker comment so they are easy
|
|
108
132
|
to find and remove later.
|
|
109
133
|
"""
|
|
134
|
+
_require_valid_domain(domain)
|
|
110
135
|
path = hosts_path or hosts_path_for_os()
|
|
111
136
|
content = Path(path).read_text()
|
|
112
137
|
if _has_hosts_entry(content, domain):
|
|
@@ -151,6 +176,7 @@ def ensure_certificate(
|
|
|
151
176
|
``"self-signed"`` (openssl fallback — browser warning). Certificates
|
|
152
177
|
are cached under ``~/.workpeg/fake-cloud/<domain>/`` and reused.
|
|
153
178
|
"""
|
|
179
|
+
_require_valid_domain(domain) # it lands in a path, a subject, a SAN
|
|
154
180
|
root = Path(cert_root) if cert_root else _CERT_ROOT
|
|
155
181
|
cert_dir = root / domain
|
|
156
182
|
cert = cert_dir / "cert.pem"
|
|
@@ -178,6 +204,7 @@ def ensure_certificate(
|
|
|
178
204
|
check=True,
|
|
179
205
|
capture_output=True,
|
|
180
206
|
)
|
|
207
|
+
_lock_down_key(key)
|
|
181
208
|
kind_file.write_text("mkcert")
|
|
182
209
|
return str(cert), str(key), "mkcert"
|
|
183
210
|
except subprocess.CalledProcessError:
|
|
@@ -198,6 +225,7 @@ def ensure_certificate(
|
|
|
198
225
|
check=True,
|
|
199
226
|
capture_output=True,
|
|
200
227
|
)
|
|
228
|
+
_lock_down_key(key)
|
|
201
229
|
kind_file.write_text("self-signed")
|
|
202
230
|
return str(cert), str(key), "self-signed"
|
|
203
231
|
|
|
@@ -209,14 +237,22 @@ def ensure_certificate(
|
|
|
209
237
|
|
|
210
238
|
def pick_https_port(preferred: int = 443, fallback: int = 8443) -> int:
|
|
211
239
|
"""Best HTTPS port available: 443 (no port in URL) when bindable,
|
|
212
|
-
else 8443, else the first free port above 8443.
|
|
240
|
+
else 8443, else the first free port above 8443.
|
|
241
|
+
|
|
242
|
+
Probes the bind on the *same* host the dev server will use
|
|
243
|
+
(``_bind_host()``, loopback by default) — not ``0.0.0.0`` — so the result
|
|
244
|
+
matches reality. On macOS a non-root process can bind 443 on all interfaces
|
|
245
|
+
but not on 127.0.0.1, so probing loopback correctly falls back to 8443
|
|
246
|
+
instead of handing back a 443 the server then can't bind."""
|
|
247
|
+
from workpeg.ui.serve import _bind_host, find_free_port
|
|
248
|
+
|
|
249
|
+
host = _bind_host()
|
|
213
250
|
for port in (preferred, fallback):
|
|
214
251
|
try:
|
|
215
252
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
216
|
-
s.bind((
|
|
253
|
+
s.bind((host, port))
|
|
217
254
|
return port
|
|
218
255
|
except OSError:
|
|
219
256
|
continue
|
|
220
|
-
from workpeg.ui.serve import find_free_port
|
|
221
257
|
|
|
222
258
|
return find_free_port(start=fallback + 1)
|
|
@@ -212,6 +212,43 @@
|
|
|
212
212
|
});
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// Momentary "copied" confirmation on an icon copy button: swap its glyph to a
|
|
216
|
+
// check and its label to "Copied", then restore after a beat. Re-clicking
|
|
217
|
+
// resets the timer instead of stacking them, and the resting icon/label are
|
|
218
|
+
// captured only on the first flash so rapid clicks can't lose them.
|
|
219
|
+
function flashCopied(btn) {
|
|
220
|
+
if (!btn) return;
|
|
221
|
+
var ic = btn.querySelector("i");
|
|
222
|
+
if (btn._copiedTimer) {
|
|
223
|
+
clearTimeout(btn._copiedTimer);
|
|
224
|
+
} else if (ic) {
|
|
225
|
+
btn._restIcon = ic.className;
|
|
226
|
+
btn._restLabel = btn.getAttribute("aria-label");
|
|
227
|
+
}
|
|
228
|
+
if (ic) ic.className = "ti ti-check";
|
|
229
|
+
btn.classList.add("is-copied");
|
|
230
|
+
btn.setAttribute("aria-label", "Copied");
|
|
231
|
+
btn.setAttribute("title", "Copied");
|
|
232
|
+
btn._copiedTimer = setTimeout(function () {
|
|
233
|
+
if (ic) ic.className = btn._restIcon || "ti ti-copy";
|
|
234
|
+
btn.classList.remove("is-copied");
|
|
235
|
+
if (btn._restLabel) {
|
|
236
|
+
btn.setAttribute("aria-label", btn._restLabel);
|
|
237
|
+
btn.setAttribute("title", btn._restLabel);
|
|
238
|
+
}
|
|
239
|
+
btn._copiedTimer = null;
|
|
240
|
+
}, 1200);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// An icon copy button that confirms the copy with a momentary check.
|
|
244
|
+
function copyBtn(getText, extraCls) {
|
|
245
|
+
var btn = iconBtn("copy", "Copy", function () {
|
|
246
|
+
copyText(getText());
|
|
247
|
+
flashCopied(btn);
|
|
248
|
+
}, extraCls);
|
|
249
|
+
return btn;
|
|
250
|
+
}
|
|
251
|
+
|
|
215
252
|
function escapeHtml(s) {
|
|
216
253
|
return String(s == null ? "" : s)
|
|
217
254
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
@@ -666,7 +703,7 @@
|
|
|
666
703
|
var bubble = el("div", "chat-message__bubble", { text: m.content });
|
|
667
704
|
var actions = el("div", "chat-message__actions chat-message__actions--user", {
|
|
668
705
|
children: [
|
|
669
|
-
|
|
706
|
+
copyBtn(function () { return m.content; })
|
|
670
707
|
]
|
|
671
708
|
});
|
|
672
709
|
return el("div", "chat-message chat-message--user", { children: [
|
|
@@ -732,8 +769,8 @@
|
|
|
732
769
|
|
|
733
770
|
function buildAiActions(node) {
|
|
734
771
|
var m = node._msg;
|
|
735
|
-
var copy =
|
|
736
|
-
|
|
772
|
+
var copy = copyBtn(function () {
|
|
773
|
+
return node._bubble.innerText || m.content; }, "chat-message__action");
|
|
737
774
|
var regen = iconBtn("refresh", "Regenerate", function () { regenerate(); },
|
|
738
775
|
"chat-message__action");
|
|
739
776
|
var right = el("div", "chat-message__actions-right", { children: [
|
|
@@ -1187,6 +1224,9 @@
|
|
|
1187
1224
|
R + " .wpai-iconbtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;color:var(--color-icon-tertiary);cursor:pointer;border-radius:var(--radius,8px);transition:all .15s ease}",
|
|
1188
1225
|
R + " .wpai-iconbtn:hover{background:var(--color-surface-sunken2);color:var(--color-icon-primary)}",
|
|
1189
1226
|
R + " .wpai-iconbtn.is-active{color:var(--color-brand-secondary,var(--color-brand-primary))}",
|
|
1227
|
+
// Momentary confirmation after a copy — the check reads as success, and this
|
|
1228
|
+
// wins over :hover (defined earlier) so it stays green even under the cursor.
|
|
1229
|
+
R + " .wpai-iconbtn.is-copied,.wpai-iconbtn.is-copied:hover{color:var(--color-success-500,#12b76a);background:transparent}",
|
|
1190
1230
|
// chips + suggestions
|
|
1191
1231
|
R + " .suggestions-section{display:grid;gap:16px;border-radius:var(--radius-lg,12px);padding:16px;width:100%}",
|
|
1192
1232
|
R + " .chips-title{font-size:var(--text-body-sm-size,13px);color:var(--color-text-secondary);margin:0}",
|
|
@@ -49,7 +49,11 @@
|
|
|
49
49
|
Workpeg._isWorkpegHost = function (url) {
|
|
50
50
|
var host = urlHost(url);
|
|
51
51
|
if (!host) return false;
|
|
52
|
-
|
|
52
|
+
// "workpeg" must be the REGISTRABLE label — the one right before the TLD.
|
|
53
|
+
// So workpeg.com and dev.x.workpeg.io match, but foo.workpeg.evil.com does
|
|
54
|
+
// NOT: a bare "workpeg" label buried in an attacker's domain is not us.
|
|
55
|
+
var labels = host.split(".");
|
|
56
|
+
return labels.length >= 2 && labels[labels.length - 2] === "workpeg";
|
|
53
57
|
};
|
|
54
58
|
|
|
55
59
|
// Core request: NO Workpeg-host guard (workpeg.* uses this directly).
|
|
@@ -117,8 +121,10 @@
|
|
|
117
121
|
(window.location && window.location.hostname) || ""
|
|
118
122
|
).toLowerCase();
|
|
119
123
|
var labels = host.split(".");
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
// Only a genuine Workpeg host (workpeg as the registrable label) names a
|
|
125
|
+
// namespace — the label right before "workpeg".
|
|
126
|
+
var i = labels.length - 2;
|
|
127
|
+
return (i > 0 && labels[i] === "workpeg") ? labels[i - 1] : "";
|
|
122
128
|
} catch (e) {
|
|
123
129
|
return "";
|
|
124
130
|
}
|
|
@@ -407,7 +413,7 @@
|
|
|
407
413
|
var ep = (window.location && window.location.pathname) || "/";
|
|
408
414
|
window.location.href =
|
|
409
415
|
Workpeg.accountsBase.replace(/\/+$/, "") +
|
|
410
|
-
"/login?peg=" + encodeURIComponent(ns) + "&ep=" + ep;
|
|
416
|
+
"/login?peg=" + encodeURIComponent(ns) + "&ep=" + encodeURIComponent(ep);
|
|
411
417
|
} catch (e) { /* no window — nothing to redirect */ }
|
|
412
418
|
};
|
|
413
419
|
|
|
@@ -4398,6 +4404,10 @@
|
|
|
4398
4404
|
}
|
|
4399
4405
|
if (props.target) {
|
|
4400
4406
|
el.target = props.target;
|
|
4407
|
+
// A new browsing context must not get a usable window.opener back to us
|
|
4408
|
+
// (reverse tabnabbing) and shouldn't leak our URL as Referer — match
|
|
4409
|
+
// HtmlView/Markdown, which already set this.
|
|
4410
|
+
if (props.target === "_blank") el.rel = "noopener noreferrer";
|
|
4401
4411
|
}
|
|
4402
4412
|
} else {
|
|
4403
4413
|
el = document.createElement("div");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import errno
|
|
3
4
|
import http.server
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
@@ -12,6 +13,11 @@ from pathlib import Path
|
|
|
12
13
|
from typing import Callable
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
class ServeError(Exception):
|
|
17
|
+
"""A dev server couldn't start. Carries an actionable message for the CLI to
|
|
18
|
+
print instead of a raw traceback."""
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
class _ReusingTCPServer(socketserver.TCPServer):
|
|
16
22
|
allow_reuse_address = True
|
|
17
23
|
|
|
@@ -370,11 +376,48 @@ def find_free_port(start: int = 8080) -> int:
|
|
|
370
376
|
)
|
|
371
377
|
|
|
372
378
|
|
|
379
|
+
def _bind_host() -> str:
|
|
380
|
+
"""Interface the dev server listens on. Localhost by default, so the app,
|
|
381
|
+
live-reload endpoint and error overlay aren't exposed to the whole LAN; set
|
|
382
|
+
``WORKPEG_BIND_HOST=0.0.0.0`` to reach it from another device on purpose."""
|
|
383
|
+
return os.environ.get("WORKPEG_BIND_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _bind_tcp_server(server_cls, port: int, handler):
|
|
387
|
+
"""Build *server_cls* bound to ``(_bind_host(), port)``, turning a failed
|
|
388
|
+
bind into a clear :class:`ServeError` (never a raw traceback).
|
|
389
|
+
|
|
390
|
+
macOS quirk worth calling out: a non-root process may bind a privileged
|
|
391
|
+
port (<1024) on *all* interfaces (``0.0.0.0``) but NOT on a single address
|
|
392
|
+
like ``127.0.0.1`` — so loopback-binding 443 fails with EACCES unless run
|
|
393
|
+
as root. That's why the message points at sudo / a high port / the
|
|
394
|
+
``WORKPEG_BIND_HOST`` opt-out rather than just failing."""
|
|
395
|
+
host = _bind_host()
|
|
396
|
+
try:
|
|
397
|
+
return server_cls((host, port), handler)
|
|
398
|
+
except PermissionError as exc:
|
|
399
|
+
if port < 1024:
|
|
400
|
+
raise ServeError(
|
|
401
|
+
f"Can't bind {host}:{port} — port {port} is privileged (<1024). "
|
|
402
|
+
f"Re-run with sudo, use a high port (drop --port to auto-pick, "
|
|
403
|
+
f"or e.g. --port 8443), or set WORKPEG_BIND_HOST=0.0.0.0 to bind "
|
|
404
|
+
f"every interface (also reachable from the LAN)."
|
|
405
|
+
) from exc
|
|
406
|
+
raise ServeError(f"Not permitted to bind {host}:{port}.") from exc
|
|
407
|
+
except OSError as exc:
|
|
408
|
+
if exc.errno == errno.EADDRINUSE:
|
|
409
|
+
raise ServeError(
|
|
410
|
+
f"{host}:{port} is already in use — stop whatever is using it, "
|
|
411
|
+
f"or pass a different --port."
|
|
412
|
+
) from exc
|
|
413
|
+
raise
|
|
414
|
+
|
|
415
|
+
|
|
373
416
|
def _create_server(
|
|
374
417
|
directory: str, port: int
|
|
375
418
|
) -> tuple[_ReusingTCPServer, str]:
|
|
376
419
|
abs_dir = str(Path(directory).resolve())
|
|
377
|
-
server = _ReusingTCPServer
|
|
420
|
+
server = _bind_tcp_server(_ReusingTCPServer, port, _make_handler(abs_dir))
|
|
378
421
|
return server, f"http://localhost:{port}"
|
|
379
422
|
|
|
380
423
|
|
|
@@ -457,7 +500,8 @@ def serve_background(
|
|
|
457
500
|
if watch_source is not None:
|
|
458
501
|
abs_dir = str(Path(directory).resolve())
|
|
459
502
|
handler_cls, broadcast = _make_reload_handler(abs_dir)
|
|
460
|
-
server: socketserver.BaseServer =
|
|
503
|
+
server: socketserver.BaseServer = _bind_tcp_server(
|
|
504
|
+
_ReloadTCPServer, port, handler_cls)
|
|
461
505
|
|
|
462
506
|
from workpeg.ui.watcher import FileWatcher
|
|
463
507
|
|
|
@@ -510,7 +554,8 @@ def serve_blocking(
|
|
|
510
554
|
if watch_source is not None:
|
|
511
555
|
abs_dir = str(Path(directory).resolve())
|
|
512
556
|
handler_cls, broadcast = _make_reload_handler(abs_dir)
|
|
513
|
-
server: socketserver.BaseServer =
|
|
557
|
+
server: socketserver.BaseServer = _bind_tcp_server(
|
|
558
|
+
_ReloadTCPServer, port, handler_cls)
|
|
514
559
|
|
|
515
560
|
from workpeg.ui.watcher import FileWatcher
|
|
516
561
|
|
|
@@ -118,6 +118,9 @@ class Link(BaseWidget):
|
|
|
118
118
|
p["label"] = self.label
|
|
119
119
|
if self.target is not None:
|
|
120
120
|
p["target"] = self.target
|
|
121
|
+
if self.aria_label:
|
|
122
|
+
# camelCase — the key the generator/runtime read (props.ariaLabel).
|
|
123
|
+
p["ariaLabel"] = self.aria_label
|
|
121
124
|
if self.icon is not None:
|
|
122
125
|
p["icon"] = self.icon
|
|
123
126
|
return p
|
|
@@ -85,6 +85,37 @@ def test_build_tar_includes_files_and_excludes_junk(tmp_path):
|
|
|
85
85
|
assert "mod.pyc" not in names # .pyc suffix excluded
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def test_build_tar_excludes_secrets_and_symlinks(tmp_path):
|
|
89
|
+
(tmp_path / "index.html").write_text("hi")
|
|
90
|
+
# Secrets a developer might leave in an asset dir — never publish these.
|
|
91
|
+
(tmp_path / ".env").write_text("SECRET=1")
|
|
92
|
+
(tmp_path / "server.key").write_text("key")
|
|
93
|
+
(tmp_path / "id_rsa").write_text("key")
|
|
94
|
+
(tmp_path / ".ssh").mkdir()
|
|
95
|
+
(tmp_path / ".ssh" / "config").write_text("x")
|
|
96
|
+
|
|
97
|
+
# A symlink escaping the tree must not be followed.
|
|
98
|
+
outside = tmp_path.parent / "outside.txt"
|
|
99
|
+
outside.write_text("secret")
|
|
100
|
+
symlink_supported = True
|
|
101
|
+
try:
|
|
102
|
+
(tmp_path / "escape.txt").symlink_to(outside)
|
|
103
|
+
except (OSError, NotImplementedError):
|
|
104
|
+
symlink_supported = False
|
|
105
|
+
|
|
106
|
+
data = build_directory_tar_gz_bytes(tmp_path)
|
|
107
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
|
108
|
+
names = set(tar.getnames())
|
|
109
|
+
|
|
110
|
+
assert "index.html" in names
|
|
111
|
+
assert ".env" not in names
|
|
112
|
+
assert "server.key" not in names
|
|
113
|
+
assert "id_rsa" not in names
|
|
114
|
+
assert not any(n.startswith(".ssh") for n in names)
|
|
115
|
+
if symlink_supported:
|
|
116
|
+
assert "escape.txt" not in names
|
|
117
|
+
|
|
118
|
+
|
|
88
119
|
def test_build_tar_missing_dir_exits(tmp_path):
|
|
89
120
|
with pytest.raises(SystemExit):
|
|
90
121
|
build_directory_tar_gz_bytes(tmp_path / "does-not-exist")
|
|
@@ -199,3 +199,83 @@ class TestMetadataContext:
|
|
|
199
199
|
|
|
200
200
|
for case in test_cases:
|
|
201
201
|
assert _MetadataContext.verify(case) is True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestTableColumnsValidation:
|
|
205
|
+
|
|
206
|
+
def test_accepts_empty_list(self, valid_table_data):
|
|
207
|
+
valid_table_data["columns"] = []
|
|
208
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
209
|
+
|
|
210
|
+
def test_accepts_list_of_dicts(self, valid_table_data):
|
|
211
|
+
valid_table_data["columns"] = [{"name": "a"}, {"name": "b"}]
|
|
212
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
213
|
+
|
|
214
|
+
def test_accepts_none(self, valid_table_data):
|
|
215
|
+
valid_table_data["columns"] = None
|
|
216
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
217
|
+
|
|
218
|
+
def test_rejects_non_list(self, valid_table_data):
|
|
219
|
+
valid_table_data["columns"] = {"not": "a list"}
|
|
220
|
+
with pytest.raises(
|
|
221
|
+
ValueError,
|
|
222
|
+
match="Columns must be a list of column definitions.",
|
|
223
|
+
):
|
|
224
|
+
_TableContext.verify(valid_table_data)
|
|
225
|
+
|
|
226
|
+
def test_rejects_non_dict_entries(self, valid_table_data):
|
|
227
|
+
valid_table_data["columns"] = ["name", "age"]
|
|
228
|
+
with pytest.raises(
|
|
229
|
+
ValueError,
|
|
230
|
+
match="Each column must be a dictionary.",
|
|
231
|
+
):
|
|
232
|
+
_TableContext.verify(valid_table_data)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestTablePegValidation:
|
|
236
|
+
|
|
237
|
+
def test_accepts_positive_int(self, valid_table_data):
|
|
238
|
+
valid_table_data["peg"] = 10
|
|
239
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
240
|
+
|
|
241
|
+
def test_accepts_namespace_string(self, valid_table_data):
|
|
242
|
+
valid_table_data["peg"] = "workpeg.hr"
|
|
243
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
244
|
+
|
|
245
|
+
def test_accepts_none(self, valid_table_data):
|
|
246
|
+
valid_table_data["peg"] = None
|
|
247
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
248
|
+
|
|
249
|
+
def test_rejects_zero_and_negative(self, valid_table_data):
|
|
250
|
+
for bad in (0, -5):
|
|
251
|
+
valid_table_data["peg"] = bad
|
|
252
|
+
with pytest.raises(
|
|
253
|
+
ValueError,
|
|
254
|
+
match="Peg id must be a positive integer.",
|
|
255
|
+
):
|
|
256
|
+
_TableContext.verify(valid_table_data)
|
|
257
|
+
|
|
258
|
+
def test_rejects_bool(self, valid_table_data):
|
|
259
|
+
# bool is an int subclass — must not sneak through as a peg id.
|
|
260
|
+
valid_table_data["peg"] = True
|
|
261
|
+
with pytest.raises(
|
|
262
|
+
ValueError,
|
|
263
|
+
match="Peg must be an integer id, a string, or None.",
|
|
264
|
+
):
|
|
265
|
+
_TableContext.verify(valid_table_data)
|
|
266
|
+
|
|
267
|
+
def test_rejects_wrong_type(self, valid_table_data):
|
|
268
|
+
valid_table_data["peg"] = {"id": 1}
|
|
269
|
+
with pytest.raises(
|
|
270
|
+
ValueError,
|
|
271
|
+
match="Peg must be an integer id, a string, or None.",
|
|
272
|
+
):
|
|
273
|
+
_TableContext.verify(valid_table_data)
|
|
274
|
+
|
|
275
|
+
def test_rejects_blank_string(self, valid_table_data):
|
|
276
|
+
valid_table_data["peg"] = " "
|
|
277
|
+
with pytest.raises(
|
|
278
|
+
ValueError,
|
|
279
|
+
match="Peg string must be non-empty.",
|
|
280
|
+
):
|
|
281
|
+
_TableContext.verify(valid_table_data)
|
|
@@ -53,7 +53,7 @@ def test_run_with_docker_builds_first(monkeypatch, tmp_path):
|
|
|
53
53
|
cmd, check = calls[0]
|
|
54
54
|
assert cmd == [
|
|
55
55
|
"docker", "run", "-d",
|
|
56
|
-
"-p", "9000:9000",
|
|
56
|
+
"-p", "127.0.0.1:9000:9000",
|
|
57
57
|
"--network", "workpeg_net",
|
|
58
58
|
"--restart", "unless-stopped",
|
|
59
59
|
"built-image",
|
|
@@ -132,7 +132,7 @@ def test_run_with_docker_no_build(monkeypatch, tmp_path):
|
|
|
132
132
|
cmd, check = calls[0]
|
|
133
133
|
assert cmd == [
|
|
134
134
|
"docker", "run", "-d",
|
|
135
|
-
"-p", "8000:8000",
|
|
135
|
+
"-p", "127.0.0.1:8000:8000",
|
|
136
136
|
"--network", "bridge",
|
|
137
137
|
"--restart", "unless-stopped",
|
|
138
138
|
"configured-image",
|
|
@@ -307,7 +307,7 @@ def test_run_with_docker_expose(monkeypatch, tmp_path):
|
|
|
307
307
|
cmd, check = calls[0]
|
|
308
308
|
assert cmd == [
|
|
309
309
|
"docker", "run", "-d",
|
|
310
|
-
"-p", "9000:9000",
|
|
310
|
+
"-p", "127.0.0.1:9000:9000",
|
|
311
311
|
"--restart", "unless-stopped",
|
|
312
312
|
"demo",
|
|
313
313
|
]
|
|
@@ -137,6 +137,68 @@ def test_build_tar_gz_bytes_excludes_common_junk(tmp_path: Path):
|
|
|
137
137
|
assert ".DS_Store" not in paths
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def test_build_tar_gz_bytes_excludes_secrets(tmp_path: Path, capsys):
|
|
141
|
+
# A valid function project...
|
|
142
|
+
(tmp_path / "Dockerfile").write_text("FROM scratch")
|
|
143
|
+
(tmp_path / "app").mkdir()
|
|
144
|
+
(tmp_path / "app" / "main.py").write_text("x=1\n")
|
|
145
|
+
|
|
146
|
+
# ...alongside secrets a careless developer might leave in the tree.
|
|
147
|
+
(tmp_path / ".env").write_text("SECRET=hunter2")
|
|
148
|
+
(tmp_path / ".env.local").write_text("TOKEN=abc")
|
|
149
|
+
(tmp_path / "server.key").write_text("-----BEGIN PRIVATE KEY-----")
|
|
150
|
+
(tmp_path / "tls.pem").write_text("-----BEGIN CERTIFICATE-----")
|
|
151
|
+
(tmp_path / "id_rsa").write_text("ssh-key")
|
|
152
|
+
(tmp_path / "credentials").write_text("[default]")
|
|
153
|
+
(tmp_path / ".ssh").mkdir()
|
|
154
|
+
(tmp_path / ".ssh" / "known_hosts").write_text("host")
|
|
155
|
+
|
|
156
|
+
tgz = submit.build_tar_gz_bytes(tmp_path)
|
|
157
|
+
paths = list_tar_paths(tgz)
|
|
158
|
+
|
|
159
|
+
assert "app/main.py" in paths
|
|
160
|
+
for leaked in (
|
|
161
|
+
".env", ".env.local", "server.key", "tls.pem",
|
|
162
|
+
"id_rsa", "credentials",
|
|
163
|
+
):
|
|
164
|
+
assert leaked not in paths, f"{leaked} must never be bundled"
|
|
165
|
+
assert all(not p.startswith(".ssh/") for p in paths)
|
|
166
|
+
# And the developer is told what was withheld.
|
|
167
|
+
assert "looks like a secret" in capsys.readouterr().out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_build_tar_gz_bytes_skips_symlinks(tmp_path: Path):
|
|
171
|
+
(tmp_path / "Dockerfile").write_text("FROM scratch")
|
|
172
|
+
(tmp_path / "app").mkdir()
|
|
173
|
+
(tmp_path / "app" / "main.py").write_text("x=1\n")
|
|
174
|
+
|
|
175
|
+
# A symlink pointing at something outside the project must never be followed
|
|
176
|
+
# into the bundle.
|
|
177
|
+
outside = tmp_path.parent / "outside_secret.txt"
|
|
178
|
+
outside.write_text("do not exfiltrate")
|
|
179
|
+
link = tmp_path / "link_out.txt"
|
|
180
|
+
try:
|
|
181
|
+
link.symlink_to(outside)
|
|
182
|
+
except (OSError, NotImplementedError):
|
|
183
|
+
pytest.skip("symlinks unsupported on this platform")
|
|
184
|
+
|
|
185
|
+
tgz = submit.build_tar_gz_bytes(tmp_path)
|
|
186
|
+
paths = list_tar_paths(tgz)
|
|
187
|
+
|
|
188
|
+
assert "app/main.py" in paths
|
|
189
|
+
assert "link_out.txt" not in paths
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_looks_secret_matches_expected_names():
|
|
193
|
+
for name in (
|
|
194
|
+
".env", ".ENV", ".env.production", ".netrc", "id_ed25519",
|
|
195
|
+
"key.pem", "cert.PEM", "store.p12", "app.keystore",
|
|
196
|
+
):
|
|
197
|
+
assert submit._looks_secret(name) is True
|
|
198
|
+
for name in ("main.py", "requirements.txt", "environment.yml", "readme.md"):
|
|
199
|
+
assert submit._looks_secret(name) is False
|
|
200
|
+
|
|
201
|
+
|
|
140
202
|
def test_build_tar_gz_bytes_bad_path(tmp_path: Path):
|
|
141
203
|
bad = tmp_path / "does-not-exist"
|
|
142
204
|
with pytest.raises(SystemExit) as e:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|