solari-runloop-compat 0.1.0__tar.gz → 0.1.2__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.
Files changed (58) hide show
  1. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/BLUEPRINTS.md +2 -2
  2. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/COMPAT.md +13 -7
  3. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/PKG-INFO +3 -3
  4. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/pyproject.toml +2 -2
  5. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/__init__.py +1 -1
  6. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_dockerfile.py +30 -82
  7. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_gateway.py +30 -12
  8. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_mapping.py +15 -2
  9. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_context.py +4 -2
  10. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_dockerfile.py +44 -32
  11. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_gateway_wire.py +30 -0
  12. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_live_staging.py +16 -0
  13. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_mapping.py +41 -1
  14. solari_runloop_compat-0.1.0/solari_runloop/_guest/solari-run.sh +0 -14
  15. solari_runloop_compat-0.1.0/solari_runloop/_guest/solari_extract.py +0 -101
  16. solari_runloop_compat-0.1.0/solari_runloop/_guest/solari_sizecheck.py +0 -14
  17. solari_runloop_compat-0.1.0/tests/test_extract.py +0 -111
  18. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/.gitignore +0 -0
  19. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/GATEWAY-ENDPOINTS.md +0 -0
  20. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/NOTICE +0 -0
  21. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/README.md +0 -0
  22. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_branding.py +0 -0
  23. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_client.py +0 -0
  24. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_config.py +0 -0
  25. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_context.py +0 -0
  26. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_exceptions.py +0 -0
  27. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_loop.py +0 -0
  28. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_models.py +0 -0
  29. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_resources.py +0 -0
  30. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/_sync.py +0 -0
  31. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/lib/__init__.py +0 -0
  32. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/lib/polling.py +0 -0
  33. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/pagination.py +0 -0
  34. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/sdk.py +0 -0
  35. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/__init__.py +0 -0
  36. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_build_log.py +0 -0
  37. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_build_logs_list_view.py +0 -0
  38. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_build_parameters.py +0 -0
  39. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/blueprint_view.py +0 -0
  40. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_async_execution_detail_view.py +0 -0
  41. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_execution_detail_view.py +0 -0
  42. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_snapshot_list_view.py +0 -0
  43. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_snapshot_view.py +0 -0
  44. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devbox_view.py +0 -0
  45. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devboxes/__init__.py +0 -0
  46. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/devboxes/devbox_logs_list_view.py +0 -0
  47. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/secret_view.py +0 -0
  48. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/__init__.py +0 -0
  49. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/after_idle.py +0 -0
  50. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/code_mount_parameters.py +0 -0
  51. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/launch_parameters.py +0 -0
  52. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/lifecycle_configuration.py +0 -0
  53. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/lifecycle_hooks.py +0 -0
  54. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/shared/resume_triggers.py +0 -0
  55. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/solari_runloop/types/tunnel_view.py +0 -0
  56. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/run_runloop_examples.sh +0 -0
  57. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_config.py +0 -0
  58. {solari_runloop_compat-0.1.0 → solari_runloop_compat-0.1.2}/tests/test_surface.py +0 -0
@@ -42,7 +42,7 @@ pipeline) use `{op:"workdir", dir}`; `localFiles` is rejected (`LocalFilesUnsupp
42
42
  `.dockerignore` is applied client-side when the SDK uploads the directory (`storage_object.upload_from_dir`).
43
43
  Gateway work: `contextObjectId` validation (same org), the `copy` op in the build executor, ~2 days; tests for wrong-org objects.
44
44
 
45
- ## Level 3a: any public `FROM <image>`, shipped in this PR (compat layer only, staging-verified)
45
+ ## Level 3a: any public `FROM <image>` by chroot import (SUPERSEDED by 3b for new builds; Python compat now emits `fromImage`)
46
46
  `FROM python:3.12-alpine`, `FROM node:20`, `FROM ghcr.io/org/img:tag`, and (with `build_args={"SOLARI_IMPORT": "1"}`) `FROM ubuntu`/`debian`
47
47
  **as an imported image** instead of the native base. How: the build steps (a) install `crane` (pinned `v0.20.3`, tarball sha256 checked
48
48
  at download, the binary's sha256 **re-checked before every use**, the build fails on a mismatch), (b) check the manifest size against
@@ -67,7 +67,7 @@ VM today (the builder disk size is not configurable yet, so big images fail on d
67
67
  `/etc/solari-cmd` but **not started**; public registries only; amd64 only; absolute symlinks inside the image resolve against the host in the file API,
68
68
  so reading such a path is refused (exec through `solari-run` resolves them correctly); processes from different `exec` calls share the chroot, but there is
69
69
  no PID 1/init in the image (no systemd), and a `RUN` that needs a running daemon fails.
70
- A warning is logged at compile time whenever import mode is used.
70
+ The Python compat layer no longer emits these steps (it sends `compiled.fromImage`, level 3b); the exec/fs marker-file handling stays for templates built earlier.
71
71
 
72
72
  ## Level 3b: native, for every SDK (assigned to bug1; archi-sandbox's block list applies)
73
73
  guest-agent `rootDir` mode (exec chroots, fs ops use a symlink-safe join, rootDir immutable, capabilities dropped), `fromImage` on `POST /templates`
@@ -29,7 +29,7 @@ gateway endpoint (see `GATEWAY-ENDPOINTS.md`); **NO** raises `SolariCompatUnsupp
29
29
  | `devbox.from_id(id)`, `devbox.list(**)` | OK, live | |
30
30
  | `Devbox.get_info / await_running / await_suspended / shutdown / suspend / resume / keep_alive` | OK, live | `suspend`/`resume` = Solari pause/resume (RAM+disk). `keep_alive` re-arms the idle window the devbox was created with (default 30 min). |
31
31
  | `with ... as devbox` (sync) / `async with` | OK | Shuts the devbox down on exit. |
32
- | `devbox.cmd.exec(cmd, stdout=, stderr=, output=, last_n=)` | OK, live | Runs `bash -lc "<cmd>"` from `$HOME`. Callbacks receive lines. Output is never truncated (`*_truncated` is always false). `ExecutionResult.stdout(num_lines=)` etc. as in Runloop. |
32
+ | `devbox.cmd.exec(cmd, stdout=, stderr=, output=, last_n=)` | OK, live | Runs `bash -lc "<cmd>"` from `$HOME`; on a devbox built from an image (`FROM <image>`) it runs a non-login `bash -c` when the image has `/bin/bash` and `sh -c` when it does not (Alpine), so the image's own `PATH` survives (a login shell would reset it). As a consequence `~/.profile` and `/etc/profile.d` are not read on image devboxes. Callbacks receive lines. Output is never truncated (`*_truncated` is always false). `ExecutionResult.stdout(num_lines=)` etc. as in Runloop. |
33
33
  | `devbox.cmd.exec_async(...)` -> `Execution` | DIFF | `result()`, `get_state()`, `kill()`, `send_std_in()`, `join()` work **inside the creating process** (an in-memory registry). On a gateway with durable executions (pinetree-desktop#570, detected by probing `GET /sandboxes/{id}/executions`) every command runs through it, so `executions.retrieve / await_completed / kill / send_std_in` also work by `(devbox_id, execution_id)` from **another process**; callbacks are fed by polling the execution (~0.4 s). Against a gateway without it, the in-process registry above is used. `close_std_in` NO. |
34
34
  | `devbox.shell(name).exec / exec_async / delete`, `shell_name=` | DIFF | Wired to the gateway's named shells (pinetree-desktop#569): one persistent bash per name (cwd, env, functions, jobs persist). Output reaches `stdout=/stderr=/output=` callbacks when the command ends, not while it runs; a shell command cannot be killed or given stdin (end the shell with `shell.delete()`). Raises `SolariCompatUnsupported` on a gateway or sandbox image without shells (the guest image needs a new golden). Not yet run against staging: #569 is not deployed. |
35
35
  | `devbox.file.read / write / upload / download` | OK, live | Relative paths resolve against `$HOME`; parent dirs are created on write/upload. `upload(file=)` takes bytes, `Path`, str path or a file object. |
@@ -38,7 +38,7 @@ gateway endpoint (see `GATEWAY-ENDPOINTS.md`); **NO** raises `SolariCompatUnsupp
38
38
  | `devbox.get_tunnel()`, `get_tunnel_url(port)`, `net.enable_tunnel / get_tunnel / remove_tunnel`, create `tunnel={'auth_mode','wake_on_http','http_keep_alive'}` | DIFF, live | Real tunnel API (pinetree-desktop#575): one switch for every guest port, `https://<label>-<port>.<preview domain>`; `tunnel_key` is the `<label>`. `authenticated` (Solari's default) returns an `auth_token` that must be sent in the **`x-solari-preview-token`** header (Runloop uses `Authorization: Bearer`); `open` needs nothing. `get_tunnel()` returns `None` when no tunnel is enabled (as Runloop). **Difference:** Solari refuses `open` + `wake_on_http` (anonymous traffic could wake and bill a sandbox); the shim raises `SolariCompatUnsupported` for it, use `authenticated`. Verified on 2 gateway replicas: 40/40 requests succeed with the token, 20/20 are refused without it, revocation takes effect within 3 s. On a gateway without the tunnel API it falls back to an open per-port preview URL. |
39
39
  | `devbox.logs(execution_id=, shell_name=)` | DIFF | Uses the gateway's `GET /sandboxes/{id}/logs` when it exists (feat2's work); until then it returns the output of commands run through **this client instance** (in-process). |
40
40
  | `devbox.create_ssh_key()`, PTY tunnels, gateway/MCP tokens, `watch_evictions` | NO | Runloop platform features. |
41
- | `blueprint.create(name, dockerfile=, system_setup_commands=, file_mounts=, base_blueprint_name=/id=, build_args=, launch_parameters=)` | DIFF, live | Builds a Solari template from a **Dockerfile subset** and waits for it (`FROM` ubuntu/debian = the native base; **any other public image** (e.g. `python:3.12-alpine`) is imported into a chroot, see the limits below; `RUN ENV ARG WORKDIR ENTRYPOINT CMD`; `COPY`/`ADD` with a `build_context` (see below); not multi-stage, `USER`). See `BLUEPRINTS.md`. `build_context`, `code_mounts`, `services`, `secrets` NO. |
41
+ | `blueprint.create(name, dockerfile=, system_setup_commands=, file_mounts=, base_blueprint_name=/id=, build_args=, launch_parameters=)` | DIFF, live | Builds a Solari template from a **Dockerfile subset** and waits for it (`FROM` ubuntu/debian = the native base; **any other public image** (e.g. `python:3.12-alpine`) is built natively by the gateway (`compiled.fromImage`), see the limits below; `RUN ENV ARG WORKDIR ENTRYPOINT CMD`; `COPY`/`ADD` with a `build_context` (see below); not multi-stage, `USER`). See `BLUEPRINTS.md`. `build_context`, `code_mounts`, `services`, `secrets` NO. |
42
42
  | `blueprint.from_id / list`, `Blueprint.get_info / logs / delete` | OK, live | `list()` returns your org's custom templates only. `logs()` has no timestamps/levels (Solari returns plain lines). |
43
43
  | `storage_object.upload_from_dir/bytes/text/file`, `as_build_context()`, `from_id`, `delete` | DIFF, live | Not real storage objects: the context is a deterministic `.tar.gz` **held by this client** and embedded in the build recipe, capped at ~700 KB compressed (the template body limit is 1 MiB). Honours `.dockerignore`. `COPY`/`ADD` (incl. `--chown`, `--chmod`, globs, `ADD` of a local tar) copy from it; `COPY --from`, URLs, `../` sources are refused. A context uploaded by another process is `SolariCompatUnsupported`. Everything else on `storage_object` (create, list, download, mounts) -> GATEWAY (objects). |
44
44
  | `secret.create / from_name / from_id / update / list / delete`, `Secret.get_info / update / delete`, `client.secrets.*`, create `secrets={ENV: secret_name}` | DIFF | Solari account secrets (pinetree-desktop#583): **write-only** (no route returns a value), names `[A-Za-z][A-Za-z0-9_.-]{0,63}` double as ids, values up to 16 KiB, 200 per org, at most 20 per create, reserved env names (`LD_*`, `PATH`, `HOME`...) refused. Not exercised on staging yet: the gateway answers 501 until the org-secrets key exists, and the shim then raises `SolariCompatUnsupported`. |
@@ -86,16 +86,22 @@ figures are not forwarded). No size at all leaves Solari's defaults (2 vCPU / 2
86
86
  | `XX_LARGE` | 8 vCPU, 32 GiB | 8 vCPU, **16 GiB (capped)** |
87
87
  | `CUSTOM_SIZE` | `custom_cpu_cores`, `custom_gb_memory` | ceil(cpu) capped at 8; memory x 1024 MiB capped at 16384 |
88
88
 
89
- ### Image import limits (`FROM <image>`, BLUEPRINTS.md level 3a)
90
- The image needs a `/bin/sh` (distroless fails the build); about 2 GB of free disk in the builder; `ENTRYPOINT`/`CMD` are recorded, **not started**;
91
- public registries from an allowlist only, amd64 only, no credentials; the root is a chroot in your own microVM (not a security boundary); no init/systemd
92
- inside the image; absolute symlinks inside the image are refused by the file API (use `cmd.exec`). A warning is logged whenever import mode is used.
89
+ ### `FROM <image>` limits (native image build, BLUEPRINTS.md level 3b)
90
+ The gateway pulls the image (public registries from an allowlist only, amd64 only, no credentials), unpacks it, and runs `RUN` steps and `cmd.exec`
91
+ inside it; the root is a chroot in your own microVM (not a security boundary); no init/systemd inside the image. The image needs a shell for `RUN`.
92
+ Needs a gateway with native image builds (`POST /templates` accepting `compiled.fromImage`); until then the build fails instead of silently building a plain base.
93
+
94
+ **The image's own `ENV` is carried over**: `PATH`, `GOPATH`, `JAVA_HOME` and the like come from the image config, so `FROM golang:1.22` has `go` on `PATH`.
95
+ `ENV` values may refer to them (`ENV PATH=/opt/x/bin:$PATH` keeps the image's `/go/bin:/usr/local/go/bin`); the gateway expands `$NAME` against the
96
+ image's env and earlier `ENV` lines when it builds. `ARG` values are expanded by the client. Images from before this change (built with the old chroot
97
+ import) keep working but still lack the image's `ENV`: rebuild them.
93
98
 
94
99
  ## Status, errors, pagination
95
100
  - Status (Solari state -> `DevboxView.status`): `starting`->`initializing`, `running`->`running`, `paused`->`suspended`,
96
- `archived`->`suspended`, `releasing`->`shutdown`, `gone`->`shutdown`; anything else `failure`.
101
+ `archived`->`suspended`, `stopped`->`shutdown`, `releasing`->`shutdown`, `gone`->`shutdown`; anything else `failure`.
97
102
  `DevboxView` fields Solari does not have are synthesized: `create_time_ms` from `createdAt`, one `state_transitions`
98
103
  entry, `launch_parameters` from the real `cpu`/`memMb`, `capabilities=[]`.
104
+ - Solari's `stopped` state (guest powered itself off; `POST /sandboxes/:id/start`) has no Runloop equivalent: there is no `Devbox.start`, and a `stopped` sandbox reports status `shutdown` (same as the TypeScript package). Use `solari_sandbox`'s `start()` directly.
99
105
  - Errors: 400 `BadRequestError`, 401 `AuthenticationError`, 402 and 403 `PermissionDeniedError` (`status_code` keeps 402),
100
106
  404 `NotFoundError`, 409 `ConflictError`, 422 `UnprocessableEntityError`, 429 `RateLimitError`, 5xx `InternalServerError`;
101
107
  network -> `APIConnectionError`, timeouts -> `APITimeoutError`. `.body` is Solari's `{code,error,message,retryable}`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: solari-runloop-compat
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Runloop-compatible Python interface over Solari Sandboxes. Not affiliated with Runloop.
5
5
  Project-URL: Homepage, https://getsolari.com
6
6
  Author: Solari
@@ -9,8 +9,8 @@ License-File: NOTICE
9
9
  Keywords: compatibility,runloop,sandbox,solari
10
10
  Requires-Python: >=3.9
11
11
  Requires-Dist: pydantic<3,>=2
12
- Requires-Dist: solari-core>=0.3.1
13
- Requires-Dist: solari-sandbox>=0.2.4
12
+ Requires-Dist: solari-core>=0.4.0
13
+ Requires-Dist: solari-sandbox>=0.3.0
14
14
  Provides-Extra: test
15
15
  Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
16
16
  Requires-Dist: pytest>=7; extra == 'test'
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "solari-runloop-compat"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Runloop-compatible Python interface over Solari Sandboxes. Not affiliated with Runloop."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = { text = "MIT" }
12
12
  authors = [{ name = "Solari" }]
13
13
  keywords = ["solari", "sandbox", "runloop", "compatibility"]
14
- dependencies = ["solari-sandbox>=0.2.4", "solari-core>=0.3.1", "pydantic>=2,<3"]
14
+ dependencies = ["solari-sandbox>=0.3.0", "solari-core>=0.4.0", "pydantic>=2,<3"]
15
15
 
16
16
  [project.optional-dependencies]
17
17
  test = ["pytest>=7", "pytest-asyncio>=0.23"]
@@ -29,7 +29,7 @@ from ._exceptions import (
29
29
  from .lib.polling import PollingConfig, PollingTimeout
30
30
  from .sdk import AsyncRunloopSDK, RunloopSDK
31
31
 
32
- __version__ = "0.1.0"
32
+ __version__ = "0.1.2"
33
33
  Client = Runloop
34
34
  AsyncClient = AsyncRunloop
35
35
 
@@ -5,7 +5,8 @@ result, so a Dockerfile maps onto that recipe rather than onto an OCI image buil
5
5
  that model can do faithfully; everything else raises :class:`SolariCompatUnsupported` naming the instruction
6
6
  (BLUEPRINTS.md describes the full design and the path to arbitrary base images).
7
7
 
8
- Supported: ``FROM`` (one stage; ubuntu/debian-family or a Solari base/template), ``RUN``, ``ENV``, ``ARG`` (defaults and
8
+ Supported: ``FROM`` (one stage; ubuntu/debian-family or a Solari base/template map to the native base, any other public
9
+ image becomes the gateway's native ``compiled.fromImage`` build, BLUEPRINTS.md level 3b), ``RUN``, ``ENV``, ``ARG`` (defaults and
9
10
  ``--build-arg``), ``WORKDIR``, ``ENTRYPOINT``, ``CMD``, ``LABEL``/``EXPOSE``/``MAINTAINER`` (ignored), ``SHELL`` (bash only).
10
11
  Not yet: ``COPY``/``ADD`` (need build-context objects), multi-stage, ``USER``, ``VOLUME``, ``HEALTHCHECK``, ``ONBUILD``.
11
12
  """
@@ -18,7 +19,6 @@ import posixpath
18
19
  import re
19
20
  import logging
20
21
  import shlex
21
- from pathlib import Path
22
22
  from typing import Any, Dict, List, Optional, Tuple
23
23
 
24
24
  from . import _exceptions as ex
@@ -30,30 +30,12 @@ DEBIAN_FAMILY = {"ubuntu", "debian"}
30
30
  #: Names that select a Solari golden base directly.
31
31
  SOLARI_BASES = {"base", "workstation"}
32
32
 
33
- #: Pinned build tool for image import (BLUEPRINTS.md, level 3a). The build FAILS if the checksum does not match.
34
- CRANE_VERSION = "v0.20.3"
35
- CRANE_SHA256 = "36c67a932f489b3f2724b64af90b599a8ef2aa7b004872597373c0ad694dc059"
36
- CRANE_URL = f"https://github.com/google/go-containerregistry/releases/download/{CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz"
37
- ROOT_DIR = "/srv/root"
38
33
  CONTEXT_DIR = "/tmp/solari-context"
39
34
  #: Registries the import accepts (public pulls only; there are no credentials in the build).
40
35
  REGISTRY_ALLOWLIST = ("docker.io", "index.docker.io", "registry-1.docker.io", "ghcr.io", "public.ecr.aws", "gcr.io", "quay.io", "mcr.microsoft.com")
41
36
  #: ``build_args`` switch that forces image import even for ubuntu/debian images (default: those use the native base).
42
37
  IMPORT_ARG = "SOLARI_IMPORT"
43
38
 
44
- _GUEST = Path(__file__).parent / "_guest"
45
- #: Runs a command inside the imported root (ENV and WORKDIR applied); installed into the builder, so into every
46
- #: devbox created from the template. The compat layer's exec wrapper calls it too.
47
- SOLARI_RUN = (_GUEST / "solari-run.sh").read_text()
48
- #: Validating tar extractor (see its docstring for the guards).
49
- SOLARI_EXTRACT = (_GUEST / "solari_extract.py").read_text()
50
- SOLARI_SIZECHECK = (_GUEST / "solari_sizecheck.py").read_text()
51
- #: Caps for one import: compressed layers (checked from the manifest before downloading), extracted bytes, wall clock.
52
- MAX_COMPRESSED_BYTES = 2 << 30
53
- MAX_EXTRACTED_BYTES = 4 << 30
54
- IMPORT_TIMEOUT_S = 900
55
- CRANE_BIN_SHA256 = "675f3b2f1696c1f6bc55b1ef535163364119776999f3d1471e4558ed35bab548"
56
-
57
39
 
58
40
  def logical_lines(text: str) -> List[Tuple[str, str]]:
59
41
  """Join ``\\`` continuations, drop comments/blank lines, split into ``(INSTRUCTION, rest)``."""
@@ -84,9 +66,14 @@ def logical_lines(text: str) -> List[Tuple[str, str]]:
84
66
  DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
85
67
 
86
68
 
87
- def _subst(s: str, args: Dict[str, str]) -> str:
69
+ def _subst(s: str, args: Dict[str, str], only: Optional[set] = None) -> str:
70
+ """Expand ``$NAME`` / ``${NAME}``. With ``only``, names outside it stay literal (the gateway expands them against the
71
+ image's own config env when it builds an ``ENV`` step)."""
72
+
88
73
  def rep(m: "re.Match[str]") -> str:
89
74
  name = m.group(1) or m.group(2)
75
+ if only is not None and name not in only:
76
+ return m.group(0)
90
77
  return args.get(name, "")
91
78
 
92
79
  return re.sub(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))", rep, s)
@@ -118,7 +105,7 @@ def _registry_of(image: str) -> str:
118
105
 
119
106
 
120
107
  def _resolve_from(rest: str, args: Dict[str, str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
121
- """-> ``(base, from_template, import_image)`` for the CompiledImage."""
108
+ """-> ``(base, from_template, from_image)`` for the CompiledImage."""
122
109
  tokens = [t for t in rest.split() if not t.startswith("--")]
123
110
  if "AS" in [t.upper() for t in tokens[1:]]:
124
111
  raise ex.SolariCompatUnsupported("Multi-stage Dockerfile (FROM ... AS)", "Single-stage builds only.")
@@ -143,7 +130,7 @@ def _resolve_from(rest: str, args: Dict[str, str]) -> Tuple[Optional[str], Optio
143
130
  if registry not in REGISTRY_ALLOWLIST:
144
131
  raise ex.SolariCompatUnsupported(
145
132
  f"FROM {image}",
146
- f"Image import only pulls public images from {', '.join(REGISTRY_ALLOWLIST)} (no registry credentials in builds).",
133
+ f"FROM <image> only builds public images from {', '.join(REGISTRY_ALLOWLIST)} (no registry credentials in builds).",
147
134
  )
148
135
  return None, None, image
149
136
 
@@ -166,7 +153,8 @@ def compile_dockerfile(
166
153
  args: Dict[str, str] = {"PATH": DEFAULT_PATH, **(build_args or {})} # ARG + ENV, for $VAR substitution
167
154
  arg_only: Dict[str, str] = dict(build_args or {}) # ARGs, exported into each RUN
168
155
  seen_from = False
169
- import_image: Optional[str] = None
156
+ from_image: Optional[str] = None
157
+ arg_names: set = set(build_args or {}) # ARG / --build-arg names: expanded here; ENV names are expanded by the gateway
170
158
  workdir = "/"
171
159
  context_loaded = False
172
160
 
@@ -179,47 +167,39 @@ def compile_dockerfile(
179
167
  if base_blueprint_name:
180
168
  log.warning("base_blueprint_name %r overrides the Dockerfile FROM", base_blueprint_name)
181
169
  else:
182
- base, from_template, import_image = fb, ft, img
183
- if import_image:
184
- log.warning(
185
- "FROM %s is imported into a chroot inside the builder VM (BLUEPRINTS.md level 3a): needs a shell in the image, "
186
- "~2 GB free disk, ENTRYPOINT/CMD are recorded but not started, public registries only.",
187
- import_image,
188
- )
189
- steps.extend(import_steps(import_image))
170
+ base, from_template, from_image = fb, ft, img
171
+ if from_image:
172
+ # Native image build (level 3b): RUN steps and exec run inside the unpacked image, and the gateway
173
+ # expands ENV values against the image's own config env, so the seed PATH is not a Dockerfile variable.
174
+ if "PATH" not in (build_args or {}):
175
+ args.pop("PATH", None)
190
176
  elif instr == "ARG":
191
177
  k, eq, v = rest.partition("=")
192
178
  k = k.strip()
193
179
  args.setdefault(k, v.strip().strip('"') if eq else "")
194
180
  arg_only[k] = args[k]
181
+ arg_names.add(k)
195
182
  elif instr == "ENV":
196
- for k, v in _parse_env(_subst(rest, args)):
183
+ for k, v in _parse_env(_subst(rest, args, arg_names) if from_image else _subst(rest, args)):
197
184
  if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k):
198
185
  raise ex.SolariCompatUnsupported(f"ENV name {k!r}", "Environment variable names must match [A-Za-z_][A-Za-z0-9_]*.")
199
186
  args[k] = v
187
+ arg_names.discard(k)
200
188
  steps.append({"op": "env", "key": k, "value": v})
201
- if import_image:
202
- steps.append(_run(f"printf 'export %s=%s\\n' {shlex.quote(k)} {shlex.quote(shlex.quote(v))} >> /etc/solari-env"))
203
189
  elif instr == "RUN":
204
190
  cmd = " ".join(shlex.quote(a) for a in json.loads(rest)) if rest.startswith("[") else _subst_run(rest, arg_only)
205
- steps.append(_run("solari-run " + shlex.quote(cmd)) if import_image else _run(cmd))
191
+ steps.append(_run(cmd))
206
192
  elif instr == "WORKDIR":
207
193
  wd = _subst(rest, args)
208
194
  wd = wd if wd.startswith("/") else posixpath.normpath(posixpath.join(workdir, wd))
209
195
  workdir = wd
210
196
  steps.append({"op": "workdir", "dir": wd})
211
- if import_image:
212
- steps.append(_run(f"echo {shlex.quote(wd)} > /etc/solari-workdir && solari-run {shlex.quote('mkdir -p ' + shlex.quote(wd))}"))
213
197
  elif instr == "ENTRYPOINT":
214
198
  cmdv = _json_or_shell(_subst(rest, args))
215
199
  steps.append({"op": "entrypoint", "command": cmdv})
216
- if import_image:
217
- steps.append(_run(f"echo {shlex.quote(json.dumps(cmdv))} > /etc/solari-entrypoint"))
218
200
  elif instr == "CMD":
219
201
  cmdv = _json_or_shell(_subst(rest, args))
220
202
  steps.append({"op": "cmd", "command": cmdv})
221
- if import_image:
222
- steps.append(_run(f"echo {shlex.quote(json.dumps(cmdv))} > /etc/solari-cmd"))
223
203
  elif instr in ("LABEL", "EXPOSE", "MAINTAINER", "STOPSIGNAL"):
224
204
  continue
225
205
  elif instr == "SHELL":
@@ -231,15 +211,15 @@ def compile_dockerfile(
231
211
  if not context_loaded:
232
212
  steps.append(_context_step(context))
233
213
  context_loaded = True
234
- steps.append(_copy_step(instr, _subst(rest, args), workdir, bool(import_image)))
214
+ steps.append(_copy_step(instr, _subst(rest, args), workdir))
235
215
  else:
236
216
  raise ex.SolariCompatUnsupported(instr)
237
217
 
238
218
  for cmd in system_setup_commands or []:
239
- steps.append(_run("solari-run " + shlex.quote(cmd)) if import_image else _run(cmd))
219
+ steps.append(_run(cmd))
240
220
  for path, contents in (file_mounts or {}).items():
241
221
  b64 = base64.b64encode(contents.encode()).decode()
242
- dest = (ROOT_DIR + posixpath.normpath(path)) if import_image else path
222
+ dest = path
243
223
  steps.append(_run(f"mkdir -p \"$(dirname {shlex.quote(dest)})\" && echo {b64} | base64 -d > {shlex.quote(dest)}"))
244
224
  if not seen_from and not from_template:
245
225
  base = "base"
@@ -248,6 +228,8 @@ def compile_dockerfile(
248
228
  out: Dict[str, Any] = {"kind": "sandbox", "packages": [], "steps": steps, "fragment": "", "localFiles": []}
249
229
  if from_template:
250
230
  out["fromTemplate"] = from_template
231
+ elif from_image:
232
+ out["fromImage"] = from_image
251
233
  else:
252
234
  out["base"] = base or "base"
253
235
  return out
@@ -265,40 +247,6 @@ def _run(command: str) -> Dict[str, Any]:
265
247
  return {"op": "run", "command": command}
266
248
 
267
249
 
268
- def import_steps(image: str) -> List[Dict[str, Any]]:
269
- """Build steps that pull ``image`` into ``/srv/root`` and install the ``solari-run`` wrapper."""
270
- b64 = lambda text: base64.b64encode(text.encode()).decode() # noqa: E731
271
- img = shlex.quote(image)
272
- crane_ok = f"echo '{CRANE_BIN_SHA256} /usr/local/bin/crane' | sha256sum -c - >/dev/null || {{ echo 'solari: crane binary checksum mismatch' >&2; exit 1; }}"
273
- size_check = f"python3 /usr/local/bin/solari-size-check \"$SOLARI_IMAGE\" {MAX_COMPRESSED_BYTES}"
274
- return [
275
- _run(
276
- "set -e; mkdir -p /usr/local/bin; if [ ! -x /usr/local/bin/crane ]; then curl -fsSL " + shlex.quote(CRANE_URL) + " -o /tmp/crane.tgz && "
277
- f"echo '{CRANE_SHA256} /tmp/crane.tgz' | sha256sum -c - && tar -xzf /tmp/crane.tgz -C /usr/local/bin crane && rm -f /tmp/crane.tgz; fi; " + crane_ok
278
- ),
279
- _run(
280
- f"echo {b64(SOLARI_EXTRACT)} | base64 -d > /usr/local/bin/solari-extract && echo {b64(SOLARI_SIZECHECK)} | base64 -d > /usr/local/bin/solari-size-check && "
281
- "chmod 0755 /usr/local/bin/solari-extract /usr/local/bin/solari-size-check"
282
- ),
283
- # Build steps run under plain sh, so the pipeline (which needs pipefail) runs in an explicit bash.
284
- _run(
285
- "bash -c "
286
- + shlex.quote(
287
- "set -eo pipefail; " + crane_ok + f"; export DOCKER_CONFIG=$(mktemp -d) SOLARI_IMAGE={img}; "
288
- + size_check
289
- + f"; rm -rf {ROOT_DIR}; mkdir -p {ROOT_DIR}; "
290
- f"timeout {IMPORT_TIMEOUT_S} crane export --platform linux/amd64 {img} - | python3 /usr/local/bin/solari-extract {ROOT_DIR} {MAX_EXTRACTED_BYTES}; "
291
- 'rm -rf "$DOCKER_CONFIG"; '
292
- f"chroot {ROOT_DIR} /bin/sh -c true 2>/dev/null || {{ echo {shlex.quote('solari: image ' + image + ' has no /bin/sh; image import needs a shell (distroless images are not supported)')} >&2; exit 1; }}"
293
- )
294
- ),
295
- _run(
296
- f"echo {b64(SOLARI_RUN)} | base64 -d > /usr/local/bin/solari-run && chmod 0755 /usr/local/bin/solari-run && "
297
- f"echo {ROOT_DIR} > /etc/solari-rootdir && : > /etc/solari-env && echo / > /etc/solari-workdir"
298
- ),
299
- ]
300
-
301
-
302
250
  _GLOB_SAFE = re.compile(r"[A-Za-z0-9_./*?\[\]+@%-]+")
303
251
 
304
252
 
@@ -306,8 +254,8 @@ def _context_step(context: bytes) -> Dict[str, Any]:
306
254
  return _run(f"rm -rf {CONTEXT_DIR} && mkdir -p {CONTEXT_DIR} && echo {base64.b64encode(context).decode()} | base64 -d | tar -xz -C {CONTEXT_DIR}")
307
255
 
308
256
 
309
- def _copy_step(instr: str, rest: str, workdir: str, imported: bool) -> Dict[str, Any]:
310
- """``COPY``/``ADD`` from the extracted build context into the image (or, for an import, into the image root)."""
257
+ def _copy_step(instr: str, rest: str, workdir: str) -> Dict[str, Any]:
258
+ """``COPY``/``ADD`` from the extracted build context into the image."""
311
259
  parts = shlex.split(rest) if not rest.startswith("[") else json.loads(rest)
312
260
  flags = [p for p in parts if p.startswith("--")]
313
261
  parts = [p for p in parts if not p.startswith("--")]
@@ -327,7 +275,7 @@ def _copy_step(instr: str, rest: str, workdir: str, imported: bool) -> Dict[str,
327
275
  abs_dest = posixpath.normpath(dest if dest.startswith("/") else posixpath.join(workdir, dest))
328
276
  if dest.endswith("/") or dest in (".", "./"):
329
277
  abs_dest = abs_dest.rstrip("/") + "/"
330
- real_dest = (ROOT_DIR + abs_dest) if imported else abs_dest
278
+ real_dest = abs_dest
331
279
  quoted = " ".join(s if _GLOB_SAFE.fullmatch(s) else shlex.quote(s) for s in srcs)
332
280
  into_dir = dest.endswith("/") or len(srcs) > 1
333
281
  q_dest = shlex.quote(real_dest)
@@ -117,6 +117,7 @@ class Gateway:
117
117
  self._starting_after: Optional[bool] = None
118
118
  self._objects: Optional[bool] = None # gateway has /objects (#574)
119
119
  self._native: set = set() # devboxes whose guest agent chroots into an imported image itself
120
+ self._plain: set = set() # devboxes known NOT to be a native image build
120
121
  self._tunnel_api: Optional[bool] = None # gateway has /sandboxes/{id}/tunnel (#575)
121
122
  self._durable: Optional[bool] = None # gateway has /sandboxes/{id}/executions (pinetree-desktop#570)
122
123
  self._remote: Dict[str, str] = {} # execution id -> devbox id, for executions started through the durable API
@@ -325,22 +326,31 @@ class Gateway:
325
326
  raise mp.map_error(e) from e
326
327
  raise mp.map_error(last) from last # type: ignore[arg-type]
327
328
 
329
+ async def _is_native(self, devbox_id: str) -> bool:
330
+ """3b: the guest agent chroots natively, so exec/fs can't see the host markers. The gateway says so on the sandbox view
331
+ (`fromImage` / `rootDir`): then no wrapper, no path prefix, no cwd from us, and no bash (an Alpine image has none) or
332
+ login shell (it would reset the image's PATH)."""
333
+ if devbox_id in self._native:
334
+ return True
335
+ # One GET per new devbox id; `_plain` caches the negative so exec does not pay it again. Keep it.
336
+ if devbox_id not in self._plain:
337
+ view = await self.get_raw(devbox_id)
338
+ if view.get("fromImage") or view.get("rootDir"):
339
+ self._native.add(devbox_id)
340
+ return True
341
+ self._plain.add(devbox_id)
342
+ return False
343
+
328
344
  async def _env(self, devbox_id: str) -> Tuple[str, Optional[str], str]:
329
345
  """``(home, image_root, workdir)``: ``image_root`` is set for devboxes made from an image-import blueprint."""
330
346
  if devbox_id not in self._home:
331
- r = await self._on_channel(
332
- devbox_id,
333
- lambda h: h.commands.run("/bin/bash", args=["-lc", 'printf "%s\\n%s\\n%s\\n%s\\n" "$HOME" "$(cat /etc/solari-rootdir 2>/dev/null)" "$(cat /etc/solari-workdir 2>/dev/null)" ""']),
334
- )
347
+ native = await self._is_native(devbox_id)
348
+ probe = 'printf "%s\\n%s\\n%s\\n%s\\n" "$HOME" "$(cat /etc/solari-rootdir 2>/dev/null)" "$(cat /etc/solari-workdir 2>/dev/null)" ""'
349
+ pcmd, pargs = mp.shell_wrap(probe, native=native)
350
+ r = await self._on_channel(devbox_id, lambda h: h.commands.run(pcmd, args=pargs))
335
351
  lines = (r.stdout.split("\n") + ["", "", "", ""])[:4]
336
352
  home = lines[0].strip() or "/root"
337
- # 3b: the guest agent chroots natively, so exec/fs can't see the host markers. The gateway says so on the
338
- # sandbox view (`fromImage` / `rootDir`): then no wrapper, no path prefix, no cwd from us.
339
- view = await self.get_raw(devbox_id)
340
- native = bool(view.get("fromImage") or view.get("rootDir"))
341
353
  root = None if native else (lines[1].strip() or None)
342
- if native:
343
- self._native.add(devbox_id)
344
354
  # Some guest images name a $HOME that does not exist yet.
345
355
  await self._on_channel(devbox_id, lambda h: h.commands.run("/bin/mkdir", args=["-p", home]))
346
356
  self._home[devbox_id] = (home, root, lines[2].strip() or "/")
@@ -388,7 +398,7 @@ class Gateway:
388
398
  asyncio.get_running_loop().create_task(self._remote_watch(rec))
389
399
  return rec
390
400
  home, root, _wd = await self._env(devbox_id) # Runloop runs commands from the user's home directory.
391
- cmd, args = mp.shell_wrap(f"solari-run {shlex.quote(command)}" if root else command)
401
+ cmd, args = mp.shell_wrap(f"solari-run {shlex.quote(command)}" if root else command, native=devbox_id in self._native)
392
402
  cwd = None if devbox_id in self._native else home
393
403
  ch = await self._on_channel(devbox_id, lambda h: h.commands.start(cmd, args=args, cwd=cwd))
394
404
  rec = _Exec(execution_id=f"exec_{uuid.uuid4().hex}", devbox_id=devbox_id, command=command, handle=ch, callbacks=(stdout, stderr, output))
@@ -435,7 +445,8 @@ class Gateway:
435
445
  )
436
446
 
437
447
  async def remote_start(self, devbox_id: str, command: str, callbacks: Tuple[Optional[LogCallback], Optional[LogCallback], Optional[LogCallback]]) -> DevboxAsyncExecutionDetailView:
438
- body = {"cmd": "/bin/bash", "args": ["-lc", self._remote_script()], "env": {"SOLARI_USER_CMD": command}}
448
+ cmd, args = mp.shell_wrap(self._remote_script(), native=await self._is_native(devbox_id))
449
+ body = {"cmd": cmd, "args": args, "env": {"SOLARI_USER_CMD": command}}
439
450
  d = await self._req("POST", f"/sandboxes/{_q(devbox_id)}/executions", body, idempotency_key=new_idempotency_key())
440
451
  self._remote[d["executionId"]] = devbox_id
441
452
  if any(callbacks):
@@ -786,6 +797,13 @@ class Gateway:
786
797
  if mem_mb:
787
798
  body["memMb"] = mem_mb
788
799
  d = await self._req("POST", "/templates", body, idempotency_key=new_idempotency_key())
800
+ if compiled.get("fromImage") and not d.get("fromImage"):
801
+ # A gateway that predates native image builds ignores `fromImage` and would build a plain base template.
802
+ try:
803
+ await self.blueprint_delete(d["templateId"])
804
+ except Exception: # noqa: BLE001 - best effort: the build row is useless either way
805
+ log.warning("could not discard template %s after refusing a gateway without native image builds", d.get("templateId"))
806
+ raise ex.SolariCompatUnsupported("FROM <image>", "This gateway has no native image builds (compiled.fromImage) yet.")
789
807
  return mp.to_blueprint_view(d)
790
808
 
791
809
  async def blueprint_get(self, blueprint_id: str) -> BlueprintView:
@@ -73,6 +73,8 @@ STATUS_MAP: Dict[str, str] = {
73
73
  "running": "running",
74
74
  "paused": "suspended",
75
75
  "archived": "suspended",
76
+ # Guest powered itself off; only Solari's start() can boot it again and Runloop has no such verb, so it reads as ended.
77
+ "stopped": "shutdown",
76
78
  "releasing": "shutdown",
77
79
  "gone": "shutdown",
78
80
  }
@@ -287,8 +289,19 @@ def translate_errors() -> Iterator[None]:
287
289
  # --- exec -----------------------------------------------------------------------------------
288
290
 
289
291
 
290
- def shell_wrap(command: str) -> Tuple[str, List[str]]:
291
- """Runloop takes a shell string; Solari exec is argv with no shell."""
292
+ #: Native image devboxes: a NON-login bash when the image has one (Runloop semantics: `[[ ]]`, arrays, `{a,b}`, pipefail), else sh
293
+ #: (Alpine, busybox). The command is passed as "$1" so it is never re-parsed by the wrapper.
294
+ NATIVE_SHELL_SCRIPT = 'if [ -x /bin/bash ]; then exec /bin/bash -c "$1"; else exec /bin/sh -c "$1"; fi'
295
+
296
+
297
+ def shell_wrap(command: str, *, native: bool = False) -> Tuple[str, List[str]]:
298
+ """Runloop takes a shell string; Solari exec is argv with no shell.
299
+
300
+ A devbox built from an image (``fromImage``) runs in that image: a login shell would re-source its /etc/profile, which
301
+ on Debian-family images resets ``PATH`` and drops the image's own (``/go/bin`` ...). Those get a plain (non-login) bash
302
+ when the image has one and ``sh -c`` otherwise (Alpine has no bash), with the environment the guest agent already applied."""
303
+ if native:
304
+ return "/bin/sh", ["-c", NATIVE_SHELL_SCRIPT, "sh", command]
292
305
  return "/bin/bash", ["-lc", command]
293
306
 
294
307
 
@@ -60,9 +60,11 @@ def test_copy_steps(tmp_path):
60
60
  assert "app/main.py" in names(base64.b64decode(blob))
61
61
 
62
62
 
63
- def test_copy_into_an_imported_root(tmp_path):
63
+ def test_copy_into_a_from_image_build(tmp_path):
64
64
  c = compile_with(tmp_path, "FROM python:3.12-alpine\nCOPY README.md /srv/R.md")
65
- assert any("/srv/root/srv/R.md" in s.get("command", "") for s in c["steps"])
65
+ assert c["fromImage"] == "python:3.12-alpine"
66
+ assert any("cp -a -- README.md /srv/R.md" in s.get("command", "").replace("'", "") for s in c["steps"])
67
+ assert not any("/srv/root" in s.get("command", "") for s in c["steps"])
66
68
 
67
69
 
68
70
  def test_add_unpacks_a_local_archive(tmp_path):
@@ -68,31 +68,51 @@ def test_logical_lines_joins_continuations_and_skips_comments():
68
68
  assert df.logical_lines("RUN a \\\n # note\n b\n\n# c\nENV x=1") == [("RUN", "a b"), ("ENV", "x=1")]
69
69
 
70
70
 
71
- def test_import_mode_for_other_images():
71
+ def test_other_images_use_the_native_from_image_build():
72
72
  c = df.compile_dockerfile("FROM python:3.12-alpine\nENV A=1\nWORKDIR /app\nRUN pip --version\nENTRYPOINT [\"/e\"]")
73
- cmds = [s["command"] for s in c["steps"] if s["op"] == "run"]
74
- assert c["base"] == "base"
75
- assert "crane export --platform linux/amd64 python:3.12-alpine -" in cmds[2]
76
- assert cmds[-1].startswith("echo /app > /etc/solari-workdir") is False
77
- assert any(x.startswith("solari-run 'pip --version'") for x in cmds)
78
- assert any("/etc/solari-env" in x and "A" in x for x in cmds)
79
-
80
-
81
- def test_crane_is_pinned_and_checked_at_use():
82
- steps = [s["command"] for s in df.import_steps("python:3.12")]
83
- assert df.CRANE_SHA256 in steps[0] and "sha256sum -c" in steps[0]
84
- assert df.CRANE_BIN_SHA256 in steps[2] # the binary is re-verified before every export
85
- assert df.CRANE_VERSION in df.CRANE_URL
86
- assert "solari-extract" in steps[2] and f"timeout {df.IMPORT_TIMEOUT_S}" in steps[2]
87
- assert "DOCKER_CONFIG=$(mktemp -d)" in steps[2] # no ambient registry credentials
88
- assert "solari-size-check" in steps[2] and str(df.MAX_COMPRESSED_BYTES) in steps[2] and "has no /bin/sh" in steps[2]
89
- assert steps[2].startswith("bash -c ") and "pipefail" in steps[2]
90
- assert "\nPY" not in steps[2] # no heredocs: the build runs each step as one command
73
+ assert c["fromImage"] == "python:3.12-alpine" and "base" not in c and "fromTemplate" not in c
74
+ assert c["steps"] == [
75
+ {"op": "env", "key": "A", "value": "1"},
76
+ {"op": "workdir", "dir": "/app"},
77
+ {"op": "run", "command": "pip --version"},
78
+ {"op": "entrypoint", "command": ["/e"]},
79
+ ]
80
+ blob = repr(c)
81
+ assert "crane" not in blob and "solari-run" not in blob and "/srv/root" not in blob # no chroot shim any more
82
+
83
+
84
+ def test_from_image_env_keeps_the_images_own_variables_for_the_gateway():
85
+ # golang:1.22 has PATH=/go/bin:/usr/local/go/bin:...; the gateway expands $PATH against it, so it must not be pre-expanded here.
86
+ c = df.compile_dockerfile("FROM golang:1.22\nENV PATH=/opt/x/bin:$PATH\nENV GOFLAGS=-mod=mod:${PATH}")
87
+ assert c["fromImage"] == "golang:1.22"
88
+ assert c["steps"][0] == {"op": "env", "key": "PATH", "value": "/opt/x/bin:$PATH"}
89
+ assert c["steps"][1] == {"op": "env", "key": "GOFLAGS", "value": "-mod=mod:${PATH}"}
90
+
91
+
92
+ def test_from_image_env_chain_is_left_to_the_gateway_and_args_are_expanded_here():
93
+ c = df.compile_dockerfile("FROM node:20\nARG V=7\nENV A=/opt/$V\nENV PATH=$A/bin:$PATH")
94
+ assert [(s["key"], s["value"]) for s in c["steps"]] == [("A", "/opt/7"), ("PATH", "$A/bin:$PATH")]
95
+
96
+
97
+ def test_from_image_build_arg_path_is_a_real_argument():
98
+ c = df.compile_dockerfile("FROM node:20\nENV X=$PATH", build_args={"PATH": "/custom"})
99
+ assert c["steps"][0]["value"] == "/custom"
100
+
101
+
102
+ def test_from_image_build_arg_path_combined_with_an_env_that_references_it():
103
+ c = df.compile_dockerfile("FROM node:20\nENV PATH=/opt/x/bin:$PATH", build_args={"PATH": "/custom"})
104
+ assert c["steps"][0] == {"op": "env", "key": "PATH", "value": "/opt/x/bin:/custom"}
105
+
106
+
107
+ def test_native_base_images_still_expand_path_against_dockers_default():
108
+ c = df.compile_dockerfile("FROM ubuntu:22.04\nENV PATH=/opt/x/bin:$PATH")
109
+ assert "fromImage" not in c and c["base"] == "base"
110
+ assert c["steps"][0]["value"] == "/opt/x/bin:" + df.DEFAULT_PATH
91
111
 
92
112
 
93
113
  @pytest.mark.parametrize("image", ["ghcr.io/org/img:1", "quay.io/org/img", "public.ecr.aws/x/y:2", "python:3.12", "library/python:3.12", "docker.io/library/node:20"])
94
114
  def test_allowlisted_registries(image):
95
- assert df.compile_dockerfile(f"FROM {image}")["steps"]
115
+ assert df.compile_dockerfile(f"FROM {image}")["fromImage"] == image
96
116
 
97
117
 
98
118
  @pytest.mark.parametrize("image", ["evil.example.com/a:1", "localhost:5000/a", "10.0.0.5/a", "registry.internal/a"])
@@ -101,15 +121,15 @@ def test_other_registries_refused(image):
101
121
  df.compile_dockerfile(f"FROM {image}")
102
122
 
103
123
 
104
- def test_import_switch_forces_import_for_debian_family():
124
+ def test_import_switch_forces_from_image_for_debian_family():
105
125
  assert df.compile_dockerfile("FROM ubuntu:22.04")["steps"] == []
106
126
  forced = df.compile_dockerfile("FROM ubuntu:22.04", build_args={"SOLARI_IMPORT": "1"})
107
- assert any("crane export" in s.get("command", "") for s in forced["steps"])
127
+ assert forced["fromImage"] == "ubuntu:22.04" and "base" not in forced
108
128
 
109
129
 
110
- def test_import_file_mounts_land_inside_the_root():
130
+ def test_from_image_file_mounts_land_at_their_path():
111
131
  c = df.compile_dockerfile("FROM alpine", file_mounts={"/etc/x": "1"})
112
- assert "/srv/root/etc/x" in c["steps"][-1]["command"]
132
+ assert c["fromImage"] == "alpine" and "/etc/x" in c["steps"][-1]["command"] and "/srv/root" not in c["steps"][-1]["command"]
113
133
 
114
134
 
115
135
  @pytest.mark.parametrize("bad", ["ENV A;B=1", "ENV $(id)=1", "ENV a-b=1"])
@@ -118,14 +138,6 @@ def test_env_names_are_validated_before_they_reach_the_env_file(bad):
118
138
  df.compile_dockerfile("FROM python:3.12-alpine\n" + bad)
119
139
 
120
140
 
121
- def test_the_no_shell_message_is_shell_quoted_for_odd_image_names():
122
- import shlex
123
-
124
- cmd = df.import_steps("ghcr.io/org/we'ird:1")[2]["command"]
125
- inner = shlex.split(cmd)[2] # bash -c '<script>': the script survives one level of shell parsing intact
126
- words = shlex.split(inner.replace("{", " ").replace("}", " ").replace(";", " ; "))
127
- assert any("ird:1 has no /bin/sh" in w for w in words)
128
-
129
141
 
130
142
  def test_env_path_prepend_expands_against_the_docker_default_path():
131
143
  c = df.compile_dockerfile("FROM ubuntu:22.04\nENV PATH=/opt/x/bin:$PATH\nENV PATH=${PATH}:/opt/y")
@@ -179,6 +179,7 @@ async def test_durable_exec_start_wait_and_view():
179
179
  ("GET", "/sandboxes/sb%3A1/executions?limit=1"): {"executions": []},
180
180
  ("POST", "/sandboxes/sb%3A1/executions/ex_1/wait"): EXEC_DONE,
181
181
  ("POST", "/sandboxes/sb%3A1/executions"): EXEC_RUNNING,
182
+ ("GET", "/sandboxes/sb%3A1"): RAW,
182
183
  })
183
184
  res = await g.execute("sb:1", "echo o; exit 7")
184
185
  start = [c for c in s.calls if c[0] == "POST" and c[1].endswith("/executions")][0]
@@ -537,3 +538,32 @@ async def test_delete_snapshot_gives_up_after_the_bound_and_treats_404_as_done(m
537
538
 
538
539
  g2, _ = gw({("DELETE", "/snapshots/s"): _ex.NotFoundError("gone")})
539
540
  await g2.delete_snapshot("s")
541
+
542
+
543
+ @pytest.mark.asyncio
544
+ async def test_blueprint_from_image_refused_on_a_gateway_without_native_image_builds():
545
+ g, http = gw({("POST", "/templates"): {"templateId": "tpl_1", "name": "n", "status": "building"}, ("DELETE", "/templates/tpl_1"): {}})
546
+ with pytest.raises(sr.SolariCompatUnsupported, match="native image builds"):
547
+ await g.blueprint_create("n", {"kind": "sandbox", "fromImage": "golang:1.22", "packages": [], "steps": [], "localFiles": []}, None, None)
548
+ assert ("DELETE", "/templates/tpl_1", None) in http.calls
549
+
550
+
551
+ @pytest.mark.asyncio
552
+ async def test_blueprint_from_image_accepted_when_the_gateway_echoes_it():
553
+ g, http = gw({("POST", "/templates"): {"templateId": "tpl_2", "name": "n", "status": "building", "fromImage": "golang:1.22"}})
554
+ view = await g.blueprint_create("n", {"kind": "sandbox", "fromImage": "golang:1.22", "packages": [], "steps": [], "localFiles": []}, None, None)
555
+ assert view.id == "tpl_2" and not any(c[0] == "DELETE" for c in http.calls)
556
+
557
+
558
+ async def test_durable_exec_on_a_native_image_build_avoids_the_login_shell():
559
+ # A login shell re-sources /etc/profile, which resets PATH on Debian images; Alpine has no bash at all.
560
+ g, s = gw({
561
+ ("GET", "/sandboxes/sb%3A1/executions?limit=1"): {"executions": []},
562
+ ("POST", "/sandboxes/sb%3A1/executions/ex_1/wait"): EXEC_DONE,
563
+ ("POST", "/sandboxes/sb%3A1/executions"): EXEC_RUNNING,
564
+ ("GET", "/sandboxes/sb%3A1"): {**RAW, "fromImage": "docker.io/library/golang:1.22"},
565
+ })
566
+ await g.execute("sb:1", "echo $PATH")
567
+ start = [c for c in s.calls if c[0] == "POST" and c[1].endswith("/executions")][0]
568
+ assert start[2]["cmd"] == "/bin/sh" and start[2]["args"][0] == "-c" and "-l" not in start[2]["args"][0]
569
+ assert "/bin/bash -c" in start[2]["args"][1] and start[2]["args"][-1] == g._remote_script()
@@ -235,3 +235,19 @@ def test_phase2_name_update_paging_and_async_snapshot(sdk: sr.RunloopSDK, cleanu
235
235
  assert done.status == "ready" and done.name == "p2-snap" and done.metadata == {"m": "1"}
236
236
  up = snap.update(name="p2-snap2", metadata={"m": "2"})
237
237
  assert up.name == "p2-snap2" and up.metadata == {"m": "2"}
238
+
239
+
240
+ def test_from_image_keeps_the_images_own_path(sdk: sr.RunloopSDK, cleanup: List) -> None:
241
+ """F30: FROM golang must keep /go/bin:/usr/local/go/bin on PATH, also behind an `ENV PATH=/opt/x/bin:$PATH`."""
242
+ bp = sdk.blueprint.create(
243
+ name=f"compat-go-{int(time.time())}",
244
+ dockerfile="FROM golang:1.22\nENV PATH=/opt/x/bin:$PATH\nRUN mkdir -p /opt/x/bin\n",
245
+ )
246
+ try:
247
+ devbox = bp.create_devbox()
248
+ cleanup.append(("devbox", devbox.id))
249
+ out = devbox.cmd.exec("echo $PATH; which go").stdout()
250
+ assert out.splitlines()[0].startswith("/opt/x/bin:/go/bin:/usr/local/go/bin:")
251
+ assert out.splitlines()[1] == "/usr/local/go/bin/go"
252
+ finally:
253
+ bp.delete()
@@ -42,7 +42,7 @@ def test_unknown_size_is_a_400():
42
42
 
43
43
  @pytest.mark.parametrize(
44
44
  "state,status",
45
- [("starting", "initializing"), ("running", "running"), ("paused", "suspended"), ("archived", "suspended"), ("releasing", "shutdown"), ("gone", "shutdown"), ("???", "failure")],
45
+ [("starting", "initializing"), ("running", "running"), ("paused", "suspended"), ("archived", "suspended"), ("stopped", "shutdown"), ("releasing", "shutdown"), ("gone", "shutdown"), ("???", "failure")],
46
46
  )
47
47
  def test_status_map(state, status):
48
48
  assert mp.map_status(state) == status
@@ -117,3 +117,43 @@ def test_shell_wrap_and_tail():
117
117
 
118
118
  def test_open_tunnel_at_create_is_accepted():
119
119
  assert mp.create_body(tunnel={"auth_mode": "open"}) == {}
120
+
121
+
122
+ def _run_native(script_cmd, *, bash_path="/bin/bash"):
123
+ import subprocess
124
+
125
+ cmd, args = mp.shell_wrap(script_cmd, native=True)
126
+ args = [a.replace("/bin/bash", bash_path) for a in args[:2]] + args[2:]
127
+ return subprocess.run([cmd, *args], capture_output=True, text=True, timeout=20)
128
+
129
+
130
+ def test_shell_wrap_plain_devbox_keeps_the_login_bash():
131
+ assert mp.shell_wrap("echo hi") == ("/bin/bash", ["-lc", "echo hi"])
132
+
133
+
134
+ def test_shell_wrap_native_runs_bash_when_the_image_has_it():
135
+ import os
136
+
137
+ if not os.access("/bin/bash", os.X_OK):
138
+ pytest.skip("no /bin/bash on this machine")
139
+ r = _run_native('[[ a == a ]] && echo "bash:$0:${BASH_VERSION:+yes}"; echo {x,y}')
140
+ assert r.returncode == 0 and "bash:" in r.stdout and "yes" in r.stdout and "x y" in r.stdout
141
+
142
+
143
+ def test_shell_wrap_native_falls_back_to_sh_when_the_image_has_no_bash():
144
+ r = _run_native("echo fallback-ok; echo \"$0\"", bash_path="/nonexistent/bash")
145
+ assert r.returncode == 0 and "fallback-ok" in r.stdout
146
+
147
+
148
+ def test_shell_wrap_native_is_not_a_login_shell():
149
+ import os
150
+
151
+ r = _run_native("shopt -q login_shell && echo login || echo notlogin") if os.access("/bin/bash", os.X_OK) else None
152
+ if r is not None:
153
+ assert "notlogin" in r.stdout
154
+
155
+
156
+ def test_shell_wrap_native_passes_the_command_as_one_argument():
157
+ cmd, args = mp.shell_wrap("echo 'a b' \"$HOME\"; exit 3", native=True)
158
+ assert args[-1] == "echo 'a b' \"$HOME\"; exit 3" and args[-2] == "sh"
159
+ assert _run_native("exit 3").returncode == 3
@@ -1,14 +0,0 @@
1
- #!/bin/sh
2
- # usage: solari-run '<shell command>' runs inside the imported image root (/etc/solari-rootdir).
3
- # Image import is a compatibility layer over a chroot in the customer's own microVM; it is not a security boundary.
4
- set -e
5
- R=$(cat /etc/solari-rootdir)
6
- mkdir -p "$R/proc" "$R/dev" "$R/sys" "$R/tmp" "$R/etc"
7
- mountpoint -q "$R/proc" || mount --bind /proc "$R/proc"
8
- mountpoint -q "$R/dev" || mount --rbind /dev "$R/dev"
9
- mountpoint -q "$R/sys" || mount --rbind /sys "$R/sys"
10
- cp /etc/resolv.conf "$R/etc/resolv.conf" 2>/dev/null || true
11
- set -a; [ -f /etc/solari-env ] && . /etc/solari-env; set +a
12
- SOLARI_WD=$(cat /etc/solari-workdir 2>/dev/null || echo /); SOLARI_CMD="$1"; export SOLARI_WD SOLARI_CMD
13
- if [ -x "$R/bin/bash" ]; then SH=/bin/bash; else SH=/bin/sh; fi
14
- exec chroot "$R" "$SH" -c 'cd "$SOLARI_WD" 2>/dev/null || cd /; eval "$SOLARI_CMD"'
@@ -1,101 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Extract a tar stream (``crane export``) into a root directory, refusing anything that could write outside it.
3
-
4
- usage: solari-extract <root> [max-bytes] < image.tar
5
- Guards (each has a test in tests/test_extract.py that fails without it):
6
- absolute member names, ``..`` components, writes through a symlink that leaves the root, hardlinks that leave the root,
7
- device nodes / fifos (skipped), setuid/setgid/sticky bits (stripped), total size and member-count caps.
8
- Runs as root inside the customer's own sandbox; this is defence in depth, the microVM is the boundary.
9
- """
10
- import os
11
- import sys
12
- import tarfile
13
-
14
- MAX_MEMBERS = 400_000
15
-
16
-
17
- class Refused(Exception):
18
- pass
19
-
20
-
21
- def extract(stream, root, max_bytes=4 << 30):
22
- root = os.path.realpath(root)
23
-
24
- def inside(p):
25
- return p == root or p.startswith(root + os.sep)
26
-
27
- total = count = skipped = 0
28
- with tarfile.open(fileobj=stream, mode="r|") as tf:
29
- for m in tf:
30
- count += 1
31
- total += max(m.size, 0)
32
- if count > MAX_MEMBERS:
33
- raise Refused("too many archive members")
34
- if total > max_bytes:
35
- raise Refused(f"image larger than the {max_bytes} byte cap")
36
- if m.name.startswith("/"):
37
- raise Refused(f"absolute member name {m.name!r}")
38
- name = os.path.normpath(m.name)
39
- if name == ".":
40
- continue
41
- if name == ".." or name.startswith("../"):
42
- raise Refused(f"member escapes the root: {m.name!r}")
43
- dest = os.path.join(root, name)
44
- parent = os.path.realpath(os.path.dirname(dest))
45
- if not inside(parent):
46
- raise Refused(f"member {m.name!r} resolves outside the root through a symlink")
47
- dest = os.path.join(parent, os.path.basename(dest))
48
- if m.isdir():
49
- if os.path.islink(dest):
50
- os.unlink(dest)
51
- os.makedirs(dest, exist_ok=True)
52
- os.chmod(dest, (m.mode & 0o777) | 0o700)
53
- continue
54
- if os.path.lexists(dest) and not os.path.isdir(dest) or os.path.islink(dest):
55
- os.unlink(dest)
56
- if m.isreg():
57
- fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, m.mode & 0o777)
58
- with os.fdopen(fd, "wb") as out:
59
- src = tf.extractfile(m)
60
- while True:
61
- chunk = src.read(1 << 20)
62
- if not chunk:
63
- break
64
- out.write(chunk)
65
- os.chmod(dest, m.mode & 0o777)
66
- elif m.issym():
67
- os.symlink(m.linkname, dest)
68
- elif m.islnk():
69
- tname = os.path.normpath(m.linkname)
70
- if m.linkname.startswith("/") or tname == ".." or tname.startswith("../"):
71
- raise Refused(f"hardlink target escapes the root: {m.linkname!r}")
72
- target = os.path.realpath(os.path.join(root, tname))
73
- if not inside(target) or not os.path.isfile(target):
74
- raise Refused(f"hardlink target outside the root or missing: {m.linkname!r}")
75
- os.link(target, dest)
76
- continue
77
- else:
78
- skipped += 1 # device nodes, fifos
79
- continue
80
- try:
81
- os.lchown(dest, m.uid, m.gid)
82
- except OSError:
83
- pass
84
- return count, skipped
85
-
86
-
87
- def main(argv):
88
- if len(argv) < 2:
89
- print(__doc__, file=sys.stderr)
90
- return 2
91
- try:
92
- n, skipped = extract(sys.stdin.buffer, argv[1], int(argv[2]) if len(argv) > 2 else 4 << 30)
93
- except Refused as e:
94
- print(f"solari-extract: refused: {e}", file=sys.stderr)
95
- return 3
96
- print(f"solari-extract: {n} members, {skipped} device nodes skipped", file=sys.stderr)
97
- return 0
98
-
99
-
100
- if __name__ == "__main__":
101
- sys.exit(main(sys.argv))
@@ -1,14 +0,0 @@
1
- #!/usr/bin/env python3
2
- """usage: solari-size-check <image> <max-compressed-bytes>: refuse an image whose compressed layers exceed the cap
3
- (read from the registry manifest before anything is downloaded)."""
4
- import json
5
- import subprocess
6
- import sys
7
-
8
- image, cap = sys.argv[1], int(sys.argv[2])
9
- m = json.loads(subprocess.check_output(["crane", "manifest", "--platform", "linux/amd64", image]))
10
- if "layers" not in m:
11
- sys.exit("solari: image manifest has no layers (multi-arch index without linux/amd64?)")
12
- size = sum(layer.get("size", 0) for layer in m["layers"])
13
- if size > cap:
14
- sys.exit(f"solari: image is {size} bytes compressed, the cap is {cap}")
@@ -1,111 +0,0 @@
1
- """The tar extractor's guards. Each test fails if the matching check in _guest/solari_extract.py is removed."""
2
-
3
- from __future__ import annotations
4
-
5
- import importlib.util
6
- import io
7
- import os
8
- import stat
9
- import tarfile
10
- from pathlib import Path
11
-
12
- import pytest
13
-
14
- spec = importlib.util.spec_from_file_location("solari_extract", Path(__file__).parent.parent / "solari_runloop" / "_guest" / "solari_extract.py")
15
- se = importlib.util.module_from_spec(spec)
16
- spec.loader.exec_module(se)
17
-
18
-
19
- def tar_of(*members):
20
- buf = io.BytesIO()
21
- with tarfile.open(fileobj=buf, mode="w") as tf:
22
- for info, data in members:
23
- tf.addfile(info, io.BytesIO(data) if data is not None else None)
24
- buf.seek(0)
25
- return buf
26
-
27
-
28
- def f(name, data=b"x", mode=0o644):
29
- i = tarfile.TarInfo(name)
30
- i.size = len(data)
31
- i.mode = mode
32
- return i, data
33
-
34
-
35
- def link(name, target, hard=False):
36
- i = tarfile.TarInfo(name)
37
- i.type = tarfile.LNKTYPE if hard else tarfile.SYMTYPE
38
- i.linkname = target
39
- return i, None
40
-
41
-
42
- def d(name):
43
- i = tarfile.TarInfo(name)
44
- i.type = tarfile.DIRTYPE
45
- i.mode = 0o755
46
- return i, None
47
-
48
-
49
- def test_normal_extraction(tmp_path):
50
- se.extract(tar_of(d("etc"), f("etc/os-release", b"A"), link("bin", "usr/bin"), d("usr"), d("usr/bin")), tmp_path)
51
- assert (tmp_path / "etc/os-release").read_bytes() == b"A" and os.path.islink(tmp_path / "bin")
52
-
53
-
54
- def test_absolute_name_refused(tmp_path):
55
- with pytest.raises(se.Refused, match="absolute"):
56
- se.extract(tar_of(f("/etc/passwd")), tmp_path)
57
-
58
-
59
- @pytest.mark.parametrize("name", ["../evil", "a/../../evil", ".."])
60
- def test_dotdot_refused(tmp_path, name):
61
- with pytest.raises(se.Refused, match="escapes"):
62
- se.extract(tar_of(f(name)), tmp_path / "root")
63
-
64
-
65
- def test_write_through_symlink_out_of_root_refused(tmp_path):
66
- root = tmp_path / "root"
67
- root.mkdir()
68
- outside = tmp_path / "outside"
69
- outside.mkdir()
70
- with pytest.raises(se.Refused, match="symlink"):
71
- se.extract(tar_of(link("evil", str(outside)), f("evil/pwned", b"x")), root)
72
- assert not (outside / "pwned").exists()
73
-
74
-
75
- def test_relative_symlink_escape_refused(tmp_path):
76
- root = tmp_path / "root"
77
- root.mkdir()
78
- with pytest.raises(se.Refused):
79
- se.extract(tar_of(link("up", "../.."), f("up/pwned")), root)
80
-
81
-
82
- def test_hardlink_out_of_root_refused(tmp_path):
83
- root = tmp_path / "root"
84
- root.mkdir()
85
- (tmp_path / "secret").write_text("s")
86
- with pytest.raises(se.Refused, match="hardlink"):
87
- se.extract(tar_of(link("h", "../secret", hard=True)), root)
88
- with pytest.raises(se.Refused, match="hardlink"):
89
- se.extract(tar_of(link("h", "/etc/passwd", hard=True)), root)
90
-
91
-
92
- def test_hardlink_inside_root_ok(tmp_path):
93
- se.extract(tar_of(f("a", b"1"), link("b", "a", hard=True)), tmp_path)
94
- assert (tmp_path / "b").read_bytes() == b"1"
95
-
96
-
97
- def test_device_nodes_skipped(tmp_path):
98
- i = tarfile.TarInfo("dev-null")
99
- i.type = tarfile.CHRTYPE
100
- n, skipped = se.extract(tar_of((i, None), f("ok")), tmp_path)
101
- assert skipped == 1 and not (tmp_path / "dev-null").exists() and (tmp_path / "ok").exists()
102
-
103
-
104
- def test_setuid_bits_stripped(tmp_path):
105
- se.extract(tar_of(f("su", b"x", mode=0o4755)), tmp_path)
106
- assert not (os.stat(tmp_path / "su").st_mode & (stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX))
107
-
108
-
109
- def test_size_cap(tmp_path):
110
- with pytest.raises(se.Refused, match="cap"):
111
- se.extract(tar_of(f("big", b"x" * 2000)), tmp_path, max_bytes=1000)