Skip to content

Commit d0aff9d

Browse files
Address PR review comments and rename target 'both' to 'combined':
- Remove unused fuse-overlayfs from Dockerfile.release (vfs driver) - Drop tar from release dependency check (no longer used) - Raise error on missing layer blob instead of warn+continue - Replace _safe_tar_extract with safe_extractall (rejects symlinks/devices) - Make checksum writing idempotent (skip write if content unchanged) - Rename --target 'both' to 'combined' across CLI, OCI, CI, and release Signed-off-by: Jacob Weinstock <jakobweinstock@gmail.com>
1 parent 6adfb48 commit d0aff9d

9 files changed

Lines changed: 30 additions & 48 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ jobs:
422422
needs: [publish-per-arch]
423423
env:
424424
ARCH: amd64
425-
TARGET: both
425+
TARGET: combined
426426
steps:
427427
- name: Checkout code
428428
uses: actions/checkout@v6

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,17 @@ jobs:
3030
- name: Install Python dependencies
3131
run: pip install -r requirements.txt
3232

33-
- name: Pull release artifacts (both)
33+
- name: Pull release artifacts (combined)
3434
env:
3535
VERSION_EXCLUDE: ${{ github.ref_name }}
36-
run: ./build.py release pull --target both --pull-output artifacts/both
36+
run: ./build.py release pull --target combined --pull-output artifacts/combined
3737

3838
- name: Create GitHub Release
3939
env:
4040
GH_TOKEN: ${{ github.token }}
4141
run: |
4242
gh release create "${{ github.ref_name }}" \
43-
artifacts/both/* \
43+
artifacts/combined/* \
4444
--generate-notes \
4545
--title "${{ github.ref_name }}"
4646

Dockerfile.release

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@
66
# docker run --rm -v $(pwd):/work captainos-release release publish
77
FROM python:3.12-slim
88

9-
# Install buildah, skopeo, git, and tar
9+
# Install buildah, skopeo, and git
1010
RUN apt-get update && apt-get install -y --no-install-recommends \
1111
buildah \
1212
skopeo \
13-
fuse-overlayfs \
1413
git \
1514
ca-certificates \
16-
tar \
1715
&& rm -rf /var/lib/apt/lists/* \
1816
&& git config --global --add safe.directory /work
1917

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ When a `v*` tag is pushed, the release workflow:
130130
./build.py release publish --target amd64
131131

132132
# Pull and extract artifacts
133-
./build.py release pull --target both --pull-output ./out/release/
133+
./build.py release pull --target combined --pull-output ./out/release/
134134

135135
# Tag all artifact images with a release version
136136
./build.py release tag v1.0.0

captain/artifacts.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,13 @@ def collect_checksums(
9696
digest = _sha256(path)
9797
lines.append(f"{digest} {path.name}")
9898
if lines:
99+
content = "\n".join(lines) + "\n"
99100
output.parent.mkdir(parents=True, exist_ok=True)
100-
output.write_text("\n".join(lines) + "\n")
101-
_log.log(f"Wrote checksums to {output}")
101+
if output.is_file() and output.read_text() == content:
102+
_log.log(f"Checksums unchanged: {output}")
103+
else:
104+
output.write_text(content)
105+
_log.log(f"Wrote checksums to {output}")
102106
for line in lines:
103107
_log.log(f" {line}")
104108
else:

captain/cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def _build_parser(command: str) -> configargparse.ArgParser:
153153
desc = "OCI release workflow: pull (or build) → publish → tag"
154154
release_cmds = {
155155
"publish": "Publish artifacts as a multi-arch OCI image",
156-
"pull": "Pull and extract artifacts (amd64, arm64, or both)",
156+
"pull": "Pull and extract artifacts (amd64, arm64, or combined)",
157157
"tag": "Tag all artifact images with a version",
158158
}
159159
commands_list = "\n".join(f" {name:14s} {d}" for name, d in release_cmds.items())
@@ -402,8 +402,8 @@ def _add_release_target_flag(parser: configargparse.ArgParser) -> None:
402402
"--target",
403403
env_var="TARGET",
404404
default=None,
405-
choices=["amd64", "arm64", "both"],
406-
help="artifact target (amd64, arm64, or both; default: --arch value)",
405+
choices=["amd64", "arm64", "combined"],
406+
help="artifact target (amd64, arm64, or combined; default: --arch value)",
407407
)
408408
g.add_argument(
409409
"--git-sha",
@@ -985,7 +985,7 @@ def _cmd_checksums(cfg: Config, _extra_args: list[str], args: object = None) ->
985985
[_add_common_flags, _add_release_base_flags, _add_release_target_flag],
986986
),
987987
"pull": (
988-
"Pull and extract artifacts (amd64, arm64, or both)",
988+
"Pull and extract artifacts (amd64, arm64, or combined)",
989989
[
990990
_add_common_flags,
991991
_add_release_base_flags,

captain/oci.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Each artifact file is pushed as its own layer so that OCI registries
44
can deduplicate blobs between per-arch and combined images.
55
6-
Combined image (``target="both"``, no tag suffix):
6+
Combined image (``target="combined"``, no tag suffix):
77
A multi-arch index where each platform manifest has the native
88
arch's layers first, then the other arch's layers (8 layers total).
99
``linux/amd64`` → ``[A1‥A4, B1‥B4]``,
@@ -262,7 +262,7 @@ def _publish_combined(
262262
digests match exactly between the per-arch and combined images.
263263
264264
If the per-arch images don't exist in the registry yet (e.g.
265-
running ``--target both`` locally with no prior per-arch publish),
265+
running ``--target combined`` locally with no prior per-arch publish),
266266
they are built and pushed first as a fallback.
267267
268268
Skips the combined image if it already exists (unless *force*).
@@ -340,20 +340,20 @@ def publish(
340340
so OCI registries deduplicate blobs automatically.
341341
342342
*target* selects which artifacts to include: ``"amd64"``,
343-
``"arm64"``, or ``"both"``.
343+
``"arm64"``, or ``"combined"``.
344344
345345
Images are skipped if they already exist in the registry
346346
(unless *force* is ``True``). For per-arch targets this prevents
347347
overwriting images that the combined image depends on.
348348
"""
349349
_log = logger or _default_log
350-
arches = list(_ARCHES) if target == "both" else [target]
351-
tag_suffix = "" if target == "both" else f"-{target}"
350+
arches = list(_ARCHES) if target == "combined" else [target]
351+
tag_suffix = "" if target == "combined" else f"-{target}"
352352
full_tag = f"{tag}{tag_suffix}"
353353
final_ref = _image_ref(registry, repository, artifact_name, full_tag)
354354

355355
# For per-arch targets, skip if the image already exists.
356-
if target != "both" and not force and skopeo.image_exists(final_ref, logger=_log):
356+
if target != "combined" and not force and skopeo.image_exists(final_ref, logger=_log):
357357
_log.log(f"{final_ref} already exists — skipping (use --force to overwrite)")
358358
return
359359

@@ -376,7 +376,7 @@ def publish(
376376
arch_layer_tars[arch] = [_deterministic_tar(f, out) for f in files]
377377

378378
try:
379-
if target == "both":
379+
if target == "combined":
380380
_publish_combined(
381381
arch_layer_tars=arch_layer_tars,
382382
registry=registry,
@@ -433,12 +433,12 @@ def pull(
433433
) -> None:
434434
"""Pull and extract OCI artifacts.
435435
436-
*target* may be ``"amd64"``, ``"arm64"``, or ``"both"``. The tag
436+
*target* may be ``"amd64"``, ``"arm64"``, or ``"combined"``. The tag
437437
suffix is ``-{target}`` for single architectures, or bare ``{tag}``
438-
for ``"both"``.
438+
for ``"combined"``.
439439
"""
440440
_log = logger or _default_log
441-
tag_suffix = "" if target == "both" else f"-{target}"
441+
tag_suffix = "" if target == "combined" else f"-{target}"
442442
ref = _image_ref(registry, repository, artifact_name, f"{tag}{tag_suffix}")
443443
skopeo.export_image(ref, output_dir, logger=_log)
444444

captain/skopeo.py

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@
88
from __future__ import annotations
99

1010
import json
11-
import os
1211
import tarfile
1312
from pathlib import Path
1413

1514
from captain.log import StageLogger, for_stage
16-
from captain.util import run
15+
from captain.util import run, safe_extractall
1716

1817
_default_log = for_stage("skopeo")
1918

@@ -98,24 +97,6 @@ def copy_to_dir(
9897
return output_dir
9998

10099

101-
def _safe_tar_extract(tar: tarfile.TarFile, output_dir: Path) -> None:
102-
"""Extract *tar* members into *output_dir*, rejecting unsafe paths.
103-
104-
Prevents path-traversal attacks where a malicious image could contain
105-
entries with ``../`` or absolute paths that write outside the target
106-
directory.
107-
"""
108-
resolved_base = output_dir.resolve()
109-
for member in tar:
110-
member_path = os.path.normpath(member.name)
111-
if os.path.isabs(member_path) or member_path.startswith(".."):
112-
raise ValueError(f"Refusing to extract tar member with unsafe path: {member.name!r}")
113-
dest = (resolved_base / member_path).resolve()
114-
if not str(dest).startswith(str(resolved_base) + os.sep) and dest != resolved_base:
115-
raise ValueError(f"Tar member escapes output directory: {member.name!r}")
116-
tar.extract(member, path=output_dir)
117-
118-
119100
def export_image(
120101
image_ref: str,
121102
output_dir: Path,
@@ -150,9 +131,8 @@ def export_image(
150131
if not blob_file.exists():
151132
blob_file = tmp_dir / digest_str.split(":")[-1]
152133
if not blob_file.exists():
153-
_log.warn(f"Layer blob not found: {digest_str}")
154-
continue
134+
raise FileNotFoundError(f"Layer blob not found: {digest_str}")
155135

156136
_log.log(f"Extracting layer {digest_str[:20]}…")
157137
with tarfile.open(blob_file, "r:*") as tf:
158-
_safe_tar_extract(tf, output_dir)
138+
safe_extractall(tf, output_dir)

captain/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def check_release_dependencies() -> list[str]:
167167
168168
Returns a list of missing command names (empty if all found).
169169
"""
170-
return _missing(["buildah", "skopeo", "git", "tar"])
170+
return _missing(["buildah", "skopeo", "git"])
171171

172172

173173
def check_dependencies(arch: str) -> list[str]:

0 commit comments

Comments
 (0)