carlos / platform Public

rootfs: the Luna pass on the Plan 1 build strings (rootfs Plan 1 Task 12)

Applies docs/superpowers/plans/LUNA-COPY-REVIEW-ROOTFS-PLAN1-BUILD.md,
approved under Paul's delegation. Changed: RFS-1-C32 (lower case after
the full stop), C55 (the source holds <n> images, for layouts too), C78
(the image data <digest>, for any blob), C88 (the CARLOS_CONTAINER_RUNTIME
value). New, approved and built: C90 (a path past 40 symlinks, its own
SymlinkChainError; C53 keeps the hardlink refusal), C91 to C97 (the
refusals of a source past the tree's bounds, each a tooLargeError that is
still errors.Is errTooLarge, without the "rootfsimage: the image is too
large to flatten" prefix) and C98 (the archive is missing <name>). The
deck, the plan's string table, rootfsCopyIDs, rootfsCopyBuilt, the code
and the tests that assert the texts change together.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 03e0c79416a0297398406387b949efb59c68532c parent d692a37
14 files changed, +138 −47
  • approved_rootfs_copy_test.go +6 −2
  • cli.go +1 −1
  • docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md +15 −6
  • docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md +15 −6
  • internal/rootfsimage/archive.go +2 −2
  • internal/rootfsimage/dir.go +1 −1
  • internal/rootfsimage/errors.go +67 −10
  • internal/rootfsimage/flatten.go +4 −4
  • internal/rootfsimage/flatten_test.go +5 −5
  • internal/rootfsimage/oci.go +3 −3
  • internal/rootfsimage/oci_test.go +14 −2
  • internal/rootfsimage/tree.go +3 −3
  • ship_runtime.go +1 −1
  • ship_runtime_test.go +1 −1
diff --git a/approved_rootfs_copy_test.go b/approved_rootfs_copy_test.go
index af0451440..ba6c1243b 100644
--- a/approved_rootfs_copy_test.go
+++ b/approved_rootfs_copy_test.go
@@ -25,7 +25,7 @@ const rootfsCopyRecordPath = "docs/superpowers/plans/2026-10-24-rootfs-apps-copy
var rootfsCopyIDPattern = regexp.MustCompile(`^RFS-[1-6]-C(0[1-9]|[1-9][0-9]{1,2})$`)
// rootfsCopyIDs is the deck's inventory, pinned as a literal list in the
-// deck's order: RFS-1-C01 to RFS-1-C89, then each later plan's rows. A
+// deck's order: RFS-1-C01 to RFS-1-C98, then each later plan's rows. A
// struck row stays in it: the deck keeps the row for history.
var rootfsCopyIDs = []string{
// Plan 1
@@ -51,7 +51,9 @@ var rootfsCopyIDs = []string{
"RFS-1-C77", "RFS-1-C78", "RFS-1-C79", "RFS-1-C80",
"RFS-1-C81", "RFS-1-C82", "RFS-1-C83", "RFS-1-C84",
"RFS-1-C85", "RFS-1-C86", "RFS-1-C87", "RFS-1-C88",
- "RFS-1-C89",
+ "RFS-1-C89", "RFS-1-C90", "RFS-1-C91", "RFS-1-C92",
+ "RFS-1-C93", "RFS-1-C94", "RFS-1-C95", "RFS-1-C96",
+ "RFS-1-C97", "RFS-1-C98",
// Plan 2
"RFS-2-C01", "RFS-2-C02", "RFS-2-C03", "RFS-2-C04",
"RFS-2-C05", "RFS-2-C06", "RFS-2-C07", "RFS-2-C08",
@@ -167,6 +169,8 @@ var rootfsCopyBuilt = []string{
"RFS-1-C49", "RFS-1-C57", "RFS-1-C58", "RFS-1-C59", "RFS-1-C88",
// Task 11: carlos ship --check (ship_check.go, ship_check_probe.go, ship_rootfs.go)
"RFS-1-C60", "RFS-1-C61", "RFS-1-C62", "RFS-1-C63", "RFS-1-C64", "RFS-1-C65", "RFS-1-C66", "RFS-1-C67", "RFS-1-C68", "RFS-1-C69", "RFS-1-C70", "RFS-1-C71", "RFS-1-C72", "RFS-1-C73", "RFS-1-C74", "RFS-1-C75", "RFS-1-C76", "RFS-1-C77", "RFS-1-C85", "RFS-1-C89",
+ // Task 12: the Luna pass on the build strings (internal/rootfsimage/errors.go, archive.go)
+ "RFS-1-C90", "RFS-1-C91", "RFS-1-C92", "RFS-1-C93", "RFS-1-C94", "RFS-1-C95", "RFS-1-C96", "RFS-1-C97", "RFS-1-C98",
}
func mustParseRootfsRecord(t *testing.T) []copyRow {
diff --git a/cli.go b/cli.go
index d959c5d11..ac816ebf0 100644
--- a/cli.go
+++ b/cli.go
@@ -2172,7 +2172,7 @@ func runShip(args []string) {
programChange := fs.Bool("program-change", false, "accept that this release's Go main package differs from the app's previous release (recorded in the manifest)")
rootfs := fs.String("rootfs", "", "an OCI image (a layout directory, a docker save archive or a local image name) or a directory, shipped as a rootfs release")
processes := fs.String("processes", "", "JSON file holding the rootfs release's processes: web, any workers, release and console")
- hibernate := fs.Bool("hibernate", false, "let this rootfs app hibernate when idle. Rootfs apps are always on unless their first release opts in")
+ hibernate := fs.Bool("hibernate", false, "let this rootfs app hibernate when idle. rootfs apps are always on unless their first release opts in")
jit := fs.Bool("jit", false, "let this rootfs release's processes run a JIT compiler, as Node, Bun, Deno, the JVM and Ruby's YJIT need. without it a box never lets memory be writable and executable at once")
check := fs.Bool("check", false, "run the rootfs image here with Docker or Podman, the way a box would, and ship nothing")
fs.Usage = flagUsage(fs, "")
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md
index c3242a35f..020fad4f8 100644
--- a/docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md
+++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-1-ship.md
@@ -158,7 +158,7 @@ Placeholders: `<app>` an app name; `<name>` a process name; `<var>` an environme
| RFS-1-C29 | internal/console/apiship_rootfs.go | version <version> was already shipped with different processes, hibernation or jit. versions are immutable, so ship under a new version | approved |
| RFS-1-C30 | cli.go | an OCI image (a layout directory, a docker save archive or a local image name) or a directory, shipped as a rootfs release | approved |
| RFS-1-C31 | cli.go | JSON file holding the rootfs release's processes: web, any workers, release and console | approved |
-| RFS-1-C32 | cli.go | let this rootfs app hibernate when idle. Rootfs apps are always on unless their first release opts in | approved |
+| RFS-1-C32 | cli.go | let this rootfs app hibernate when idle. rootfs apps are always on unless their first release opts in | approved |
| RFS-1-C33 | cli.go | run the rootfs image here with Docker or Podman, the way a box would, and ship nothing | approved |
| RFS-1-C34 | ship_rootfs.go | usage: carlos ship --app <a> --rootfs <image-or-dir> --processes <file.json> [--hibernate] [--jit] [--version <sha>] [--label <one line>] [--notes <text> \| --notes-file <file>] [--swap hold] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]\n carlos ship --rootfs <image-or-dir> --processes <file.json> [--hibernate] [--jit] --check | approved |
| RFS-1-C35 | ship_rootfs.go | --rootfs cannot be combined with --<flag> | approved |
@@ -181,7 +181,7 @@ Placeholders: `<app>` an app name; `<name>` a process name; `<var>` an environme
| RFS-1-C52 | internal/rootfsimage/errors.go | the image has <path> as a <type>, and boxes mount a directory there. remove it from the image | approved |
| RFS-1-C53 | internal/rootfsimage/errors.go | the image links <path> to <target>, which is not a file in the image | approved |
| RFS-1-C54 | internal/rootfsimage/errors.go | the image sets <var> in its environment. names that start with CARLOS_ belong to the platform, so remove it from the image | approved |
-| RFS-1-C55 | internal/rootfsimage/errors.go | the archive holds <n> images. save one image and ship that | approved |
+| RFS-1-C55 | internal/rootfsimage/errors.go | the source holds <n> images. keep one image and ship that | approved |
| RFS-1-C56 | internal/rootfsimage/dir_other.go | a directory can be shipped as a rootfs only from Linux or macOS, which keep file modes. ship an OCI image instead | approved |
| RFS-1-C57 | ship_runtime.go | --check runs the image with Docker or Podman, and neither is installed here | approved |
| RFS-1-C58 | ship_runtime.go | <source> is not a file or a directory, and there is no Docker or Podman here to export it as an image | approved |
@@ -204,7 +204,7 @@ Placeholders: `<app>` an app name; `<name>` a process name; `<var>` an environme
| RFS-1-C75 | ship_check.go | the image failed the local check | approved |
| RFS-1-C76 | ship_check_probe.go | --check needs a linux build of carlos to probe the socket from inside the container, and this one is a development build. use a released carlos, or set CARLOS_CHECK_PROBE to a linux carlos binary | approved |
| RFS-1-C77 | ship_check_probe.go | could not fetch the linux build of carlos for the check: <reason> | approved |
-| RFS-1-C78 | internal/rootfsimage/errors.go | the image's layer <digest> does not match its digest, so it was not shipped | approved |
+| RFS-1-C78 | internal/rootfsimage/errors.go | the image data <digest> does not match its digest, so it was not shipped | approved |
| RFS-1-C79 | internal/console/apicli.go | could not check this app's releases. nothing was shipped. try again | approved |
| RFS-1-C80 | internal/release/rootfs_ship.go | <app>'s first ship, <version>, made it an app for <kind> releases, so it cannot take a rootfs release. ship rootfs releases to an app of their own, made with carlos apps create | approved |
| RFS-1-C81 | internal/release/rootfs_ship.go | <app>'s first ship, <version>, made it an app for rootfs releases, so it cannot take a <kind> release. ship that release to another app | approved |
@@ -214,10 +214,19 @@ Placeholders: `<app>` an app name; `<name>` a process name; `<var>` an environme
| RFS-1-C85 | ship_check.go | the <name> process runs <program>, which needs a JIT, and this release does not declare one. it passes here but stops at start on a box. ship it with --jit | approved |
| RFS-1-C86 | internal/rootfsimage/errors.go | the rootfs image is over 2 GiB, the most one release can carry. make the image smaller and ship again | approved |
| RFS-1-C87 | internal/rootfsimage/errors.go | <path> is over 2 GiB, the largest file a rootfs image can hold. make it smaller and ship again | approved |
-| RFS-1-C88 | ship_runtime.go | CARLOS_CONTAINER_RUNTIME must be docker or podman | approved |
+| RFS-1-C88 | ship_runtime.go | the CARLOS_CONTAINER_RUNTIME value must be docker or podman | approved |
| RFS-1-C89 | ship_rootfs.go | --check ships nothing, so it cannot be combined with --<flag> | approved |
-
-RFS-1-C78's `<digest>` is a `sha256:<hex>` digest. RFS-1-C80 to C83 are the first-ship forms of C18 to C21: the app has no other release, and its kind marker (deviation 10) names the ship that fixed its kind, which may not have published, so they never say "is one". RFS-1-C85 is a warning: it does not fail the check. Kept out of the table on purpose (diagnostics, not human text): the probe's one-line JSON on stdout, `validateManifest`'s existing terse messages, store errors wrapped for logs. RFS-1-C86 and C87 were added in the build (Task 6, the controller ruling on strings found in the build), for the Luna pass at the end of Plan 1: C86 is the writer's refusal when the image passes 2 GiB while it is written (the text of C26), C87 its refusal of a single file over 2 GiB before anything is written. RFS-1-C88 was added in the build (Task 10, the controller ruling on strings found in the build): the refusal of a CARLOS_CONTAINER_RUNTIME that names neither runtime. RFS-1-C89 was added in the build (Task 11, the controller ruling on strings found in the build): --check refuses a flag only a ship uses (--app, --version, --label, --notes, --notes-file, --swap, --console, --destination, --account, --account-id, --registry, --message, --no-prompt) rather than ignore it.
+| RFS-1-C90 | internal/rootfsimage/errors.go | the image path <path> follows more than 40 symlinks, so it was not shipped | approved |
+| RFS-1-C91 | internal/rootfsimage/errors.go | the image has more than <n> entries, so it was not shipped | approved |
+| RFS-1-C92 | internal/rootfsimage/errors.go | the image's layers are too large, so it was not shipped | approved |
+| RFS-1-C93 | internal/rootfsimage/errors.go | the image's files are over <n> bytes, so it was not shipped | approved |
+| RFS-1-C94 | internal/rootfsimage/errors.go | the image lists more than <n> index entries, so it was not shipped | approved |
+| RFS-1-C95 | internal/rootfsimage/errors.go | the image data <digest> is <size> bytes, over the <max>-byte limit | approved |
+| RFS-1-C96 | internal/rootfsimage/errors.go | the image file <name> is <size> bytes, over the <max>-byte limit | approved |
+| RFS-1-C97 | internal/rootfsimage/errors.go | the archive has more than <n> entries, so it was not shipped | approved |
+| RFS-1-C98 | internal/rootfsimage/archive.go | the archive is missing <name> | approved |
+
+RFS-1-C78's `<digest>` is a `sha256:<hex>` digest. RFS-1-C80 to C83 are the first-ship forms of C18 to C21: the app has no other release, and its kind marker (deviation 10) names the ship that fixed its kind, which may not have published, so they never say "is one". RFS-1-C85 is a warning: it does not fail the check. Kept out of the table on purpose (diagnostics, not human text): the probe's one-line JSON on stdout, `validateManifest`'s existing terse messages, store errors wrapped for logs. RFS-1-C86 and C87 were added in the build (Task 6, the controller ruling on strings found in the build), for the Luna pass at the end of Plan 1: C86 is the writer's refusal when the image passes 2 GiB while it is written (the text of C26), C87 its refusal of a single file over 2 GiB before anything is written. RFS-1-C88 was added in the build (Task 10, the controller ruling on strings found in the build): the refusal of a CARLOS_CONTAINER_RUNTIME that names neither runtime. RFS-1-C89 was added in the build (Task 11, the controller ruling on strings found in the build): --check refuses a flag only a ship uses (--app, --version, --label, --notes, --notes-file, --swap, --console, --destination, --account, --account-id, --registry, --message, --no-prompt) rather than ignore it. RFS-1-C90 to C98 were added by the Luna pass on the Plan 1 build strings (docs/superpowers/plans/LUNA-COPY-REVIEW-ROOTFS-PLAN1-BUILD.md), which also changed C32, C55, C78 and C88: C90 is the refusal of a path past 40 symlinks (C53 keeps the hardlink refusal), C91 to C97 are the refusals of a source past the tree's bounds (errTooLarge), C98 is an archive missing a file its index or manifest names.
---
diff --git a/docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md b/docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md
index bc7c2875c..888716846 100644
--- a/docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md
+++ b/docs/superpowers/plans/2026-10-24-rootfs-apps-copy.md
@@ -70,7 +70,7 @@ From the plan: Placeholders: `<app>` an app name; `<name>` a process name; `<var
| RFS-1-C29 | internal/console/apiship_rootfs.go | version <version> was already shipped with different processes, hibernation or jit. versions are immutable, so ship under a new version | approved |
| RFS-1-C30 | cli.go | an OCI image (a layout directory, a docker save archive or a local image name) or a directory, shipped as a rootfs release | approved |
| RFS-1-C31 | cli.go | JSON file holding the rootfs release's processes: web, any workers, release and console | approved |
-| RFS-1-C32 | cli.go | let this rootfs app hibernate when idle. Rootfs apps are always on unless their first release opts in | approved |
+| RFS-1-C32 | cli.go | let this rootfs app hibernate when idle. rootfs apps are always on unless their first release opts in | approved |
| RFS-1-C33 | cli.go | run the rootfs image here with Docker or Podman, the way a box would, and ship nothing | approved |
| RFS-1-C34 | ship_rootfs.go | usage: carlos ship --app <a> --rootfs <image-or-dir> --processes <file.json> [--hibernate] [--jit] [--version <sha>] [--label <one line>] [--notes <text> \| --notes-file <file>] [--swap hold] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]\n carlos ship --rootfs <image-or-dir> --processes <file.json> [--hibernate] [--jit] --check | approved |
| RFS-1-C35 | ship_rootfs.go | --rootfs cannot be combined with --<flag> | approved |
@@ -93,7 +93,7 @@ From the plan: Placeholders: `<app>` an app name; `<name>` a process name; `<var
| RFS-1-C52 | internal/rootfsimage/errors.go | the image has <path> as a <type>, and boxes mount a directory there. remove it from the image | approved |
| RFS-1-C53 | internal/rootfsimage/errors.go | the image links <path> to <target>, which is not a file in the image | approved |
| RFS-1-C54 | internal/rootfsimage/errors.go | the image sets <var> in its environment. names that start with CARLOS_ belong to the platform, so remove it from the image | approved |
-| RFS-1-C55 | internal/rootfsimage/errors.go | the archive holds <n> images. save one image and ship that | approved |
+| RFS-1-C55 | internal/rootfsimage/errors.go | the source holds <n> images. keep one image and ship that | approved |
| RFS-1-C56 | internal/rootfsimage/dir_other.go | a directory can be shipped as a rootfs only from Linux or macOS, which keep file modes. ship an OCI image instead | approved |
| RFS-1-C57 | ship_runtime.go | --check runs the image with Docker or Podman, and neither is installed here | approved |
| RFS-1-C58 | ship_runtime.go | <source> is not a file or a directory, and there is no Docker or Podman here to export it as an image | approved |
@@ -116,7 +116,7 @@ From the plan: Placeholders: `<app>` an app name; `<name>` a process name; `<var
| RFS-1-C75 | ship_check.go | the image failed the local check | approved |
| RFS-1-C76 | ship_check_probe.go | --check needs a linux build of carlos to probe the socket from inside the container, and this one is a development build. use a released carlos, or set CARLOS_CHECK_PROBE to a linux carlos binary | approved |
| RFS-1-C77 | ship_check_probe.go | could not fetch the linux build of carlos for the check: <reason> | approved |
-| RFS-1-C78 | internal/rootfsimage/errors.go | the image's layer <digest> does not match its digest, so it was not shipped | approved |
+| RFS-1-C78 | internal/rootfsimage/errors.go | the image data <digest> does not match its digest, so it was not shipped | approved |
| RFS-1-C79 | internal/console/apicli.go | could not check this app's releases. nothing was shipped. try again | approved |
| RFS-1-C80 | internal/release/rootfs_ship.go | <app>'s first ship, <version>, made it an app for <kind> releases, so it cannot take a rootfs release. ship rootfs releases to an app of their own, made with carlos apps create | approved |
| RFS-1-C81 | internal/release/rootfs_ship.go | <app>'s first ship, <version>, made it an app for rootfs releases, so it cannot take a <kind> release. ship that release to another app | approved |
@@ -126,10 +126,19 @@ From the plan: Placeholders: `<app>` an app name; `<name>` a process name; `<var
| RFS-1-C85 | ship_check.go | the <name> process runs <program>, which needs a JIT, and this release does not declare one. it passes here but stops at start on a box. ship it with --jit | approved |
| RFS-1-C86 | internal/rootfsimage/errors.go | the rootfs image is over 2 GiB, the most one release can carry. make the image smaller and ship again | approved |
| RFS-1-C87 | internal/rootfsimage/errors.go | <path> is over 2 GiB, the largest file a rootfs image can hold. make it smaller and ship again | approved |
-| RFS-1-C88 | ship_runtime.go | CARLOS_CONTAINER_RUNTIME must be docker or podman | approved |
+| RFS-1-C88 | ship_runtime.go | the CARLOS_CONTAINER_RUNTIME value must be docker or podman | approved |
| RFS-1-C89 | ship_rootfs.go | --check ships nothing, so it cannot be combined with --<flag> | approved |
-
-From the plan: RFS-1-C78's `<digest>` is a `sha256:<hex>` digest. RFS-1-C80 to C83 are the first-ship forms of C18 to C21: the app has no other release, and its kind marker (deviation 10) names the ship that fixed its kind, which may not have published, so they never say "is one". RFS-1-C85 is a warning: it does not fail the check. Kept out of the table on purpose (diagnostics, not human text): the probe's one-line JSON on stdout, `validateManifest`'s existing terse messages, store errors wrapped for logs. RFS-1-C86 and C87 were added in the build (Task 6, the controller ruling on strings found in the build), for the Luna pass at the end of Plan 1: C86 is the writer's refusal when the image passes 2 GiB while it is written (the text of C26), C87 its refusal of a single file over 2 GiB before anything is written. RFS-1-C88 was added in the build (Task 10, the controller ruling on strings found in the build): the refusal of a CARLOS_CONTAINER_RUNTIME that names neither runtime. RFS-1-C89 was added in the build (Task 11, the controller ruling on strings found in the build): --check refuses a flag only a ship uses (--app, --version, --label, --notes, --notes-file, --swap, --console, --destination, --account, --account-id, --registry, --message, --no-prompt) rather than ignore it.
+| RFS-1-C90 | internal/rootfsimage/errors.go | the image path <path> follows more than 40 symlinks, so it was not shipped | approved |
+| RFS-1-C91 | internal/rootfsimage/errors.go | the image has more than <n> entries, so it was not shipped | approved |
+| RFS-1-C92 | internal/rootfsimage/errors.go | the image's layers are too large, so it was not shipped | approved |
+| RFS-1-C93 | internal/rootfsimage/errors.go | the image's files are over <n> bytes, so it was not shipped | approved |
+| RFS-1-C94 | internal/rootfsimage/errors.go | the image lists more than <n> index entries, so it was not shipped | approved |
+| RFS-1-C95 | internal/rootfsimage/errors.go | the image data <digest> is <size> bytes, over the <max>-byte limit | approved |
+| RFS-1-C96 | internal/rootfsimage/errors.go | the image file <name> is <size> bytes, over the <max>-byte limit | approved |
+| RFS-1-C97 | internal/rootfsimage/errors.go | the archive has more than <n> entries, so it was not shipped | approved |
+| RFS-1-C98 | internal/rootfsimage/archive.go | the archive is missing <name> | approved |
+
+From the plan: RFS-1-C78's `<digest>` is a `sha256:<hex>` digest. RFS-1-C80 to C83 are the first-ship forms of C18 to C21: the app has no other release, and its kind marker (deviation 10) names the ship that fixed its kind, which may not have published, so they never say "is one". RFS-1-C85 is a warning: it does not fail the check. Kept out of the table on purpose (diagnostics, not human text): the probe's one-line JSON on stdout, `validateManifest`'s existing terse messages, store errors wrapped for logs. RFS-1-C86 and C87 were added in the build (Task 6, the controller ruling on strings found in the build), for the Luna pass at the end of Plan 1: C86 is the writer's refusal when the image passes 2 GiB while it is written (the text of C26), C87 its refusal of a single file over 2 GiB before anything is written. RFS-1-C88 was added in the build (Task 10, the controller ruling on strings found in the build): the refusal of a CARLOS_CONTAINER_RUNTIME that names neither runtime. RFS-1-C89 was added in the build (Task 11, the controller ruling on strings found in the build): --check refuses a flag only a ship uses (--app, --version, --label, --notes, --notes-file, --swap, --console, --destination, --account, --account-id, --registry, --message, --no-prompt) rather than ignore it. RFS-1-C90 to C98 were added by the Luna pass on the Plan 1 build strings (docs/superpowers/plans/LUNA-COPY-REVIEW-ROOTFS-PLAN1-BUILD.md), which also changed C32, C55, C78 and C88: C90 is the refusal of a path past 40 symlinks (C53 keeps the hardlink refusal), C91 to C97 are the refusals of a source past the tree's bounds (errTooLarge), C98 is an archive missing a file its index or manifest names.
## Plan 2: box isolation (`2026-10-24-rootfs-apps-2-box-isolation.md`)
diff --git a/internal/rootfsimage/archive.go b/internal/rootfsimage/archive.go
index 3c46f0eb3..89eb1bb27 100644
--- a/internal/rootfsimage/archive.go
+++ b/internal/rootfsimage/archive.go
@@ -57,7 +57,7 @@ type archiveStore struct {
func (a *archiveStore) open(name string) (io.ReadCloser, int64, error) {
s, ok := a.files[name]
if !ok {
- return nil, 0, fmt.Errorf("rootfsimage: the archive has no %s", shown(name))
+ return nil, 0, fmt.Errorf("the archive is missing %s", shown(name))
}
return io.NopCloser(io.NewSectionReader(a.f, s.off, s.size)), s.size, nil
}
@@ -89,7 +89,7 @@ func indexArchive(f *os.File, source string) (*archiveStore, error) {
return nil, fmt.Errorf("rootfsimage: reading %s: %w", shown(source), err)
}
if n >= maxArchiveEntries {
- return nil, fmt.Errorf("%w: the archive has more than %d entries", errTooLarge, maxArchiveEntries)
+ return nil, archiveTooManyEntriesError(maxArchiveEntries)
}
if hdr.Typeflag != tar.TypeReg {
continue
diff --git a/internal/rootfsimage/dir.go b/internal/rootfsimage/dir.go
index 9b9eda4ef..5eb07c4d9 100644
--- a/internal/rootfsimage/dir.go
+++ b/internal/rootfsimage/dir.go
@@ -106,7 +106,7 @@ func readDir(t *Tree, dir string) error {
}
t.headers++
if t.headers > t.maxHeaders {
- return fmt.Errorf("%w: it has more than %d entries", errTooLarge, t.maxHeaders)
+ return tooManyEntriesError(t.maxHeaders)
}
t.add(e)
return nil
diff --git a/internal/rootfsimage/errors.go b/internal/rootfsimage/errors.go
index 7f2c8e625..5f8c5d75e 100644
--- a/internal/rootfsimage/errors.go
+++ b/internal/rootfsimage/errors.go
@@ -49,13 +49,21 @@ func (e UnsafePathError) Error() string {
}
// LinkError refuses a hardlink whose target is not a regular file of the
-// tree, and a path whose symlinks do not resolve within maxLinks (RFS-1-C53).
+// tree (RFS-1-C53).
type LinkError struct{ Path, Target string }
func (e LinkError) Error() string {
return fmt.Sprintf("the image links %s to %s, which is not a file in the image", shown(e.Path), shown(e.Target))
}
+// SymlinkChainError refuses a path whose symlinks do not resolve within
+// maxLinks, a chain or a loop (RFS-1-C90). Path is the path being resolved.
+type SymlinkChainError struct{ Path string }
+
+func (e SymlinkChainError) Error() string {
+ return fmt.Sprintf("the image path %s follows more than 40 symlinks, so it was not shipped", shown(e.Path))
+}
+
// MountPointError refuses an image that has a file or a symlink where boxes
// mount a directory (RFS-1-C52). Path is the mount point or the parent of
// one, and Type is "file" or "symlink".
@@ -73,19 +81,21 @@ func (e ProgramArchError) Error() string {
return fmt.Sprintf("%s is a program built for %s, and boxes run linux/arm64. build the directory's programs for linux/arm64", shown(e.Path), shown(e.Platform))
}
-// ManyImagesError refuses a source holding more than one image (RFS-1-C55).
+// ManyImagesError refuses a source (a save archive or an OCI layout) holding
+// more than one image (RFS-1-C55).
type ManyImagesError struct{ N int }
func (e ManyImagesError) Error() string {
- return fmt.Sprintf("the archive holds %d images. save one image and ship that", e.N)
+ return fmt.Sprintf("the source holds %d images. keep one image and ship that", e.N)
}
-// DigestError refuses a blob whose bytes do not match its descriptor's
-// digest or size (RFS-1-C78). Digest is "sha256:<hex>".
+// DigestError refuses a blob (a layer, manifest, config or index) whose
+// bytes do not match its descriptor's digest or size (RFS-1-C78). Digest is
+// "sha256:<hex>".
type DigestError struct{ Digest string }
func (e DigestError) Error() string {
- return fmt.Sprintf("the image's layer %s does not match its digest, so it was not shipped", shown(e.Digest))
+ return fmt.Sprintf("the image data %s does not match its digest, so it was not shipped", shown(e.Digest))
}
// ImageTooLargeError refuses an image that grows past the bytes one
@@ -124,12 +134,59 @@ func (e CommandError) Error() string {
return fmt.Sprintf("the %s process runs %s, which the image does not have. commands are looked up in the process's PATH, or from its working_dir when they contain a slash", shown(e.Process), shown(e.Command))
}
-// errTooLarge is wrapped by the refusal of a tree past its bounds (maxBytes,
-// maxHeaders) and of a JSON document past maxJSONBytes. carlos ship's own
-// size refusal (RFS-1-C43) speaks for the built image; this one stops a
-// decompression bomb before the spool fills the disk.
+// errTooLarge is what every refusal of a source past the tree's bounds is
+// (errors.Is): maxBytes, maxHeaders, the decoded layer bytes, the index
+// entries, a blob or a JSON document past its size, an archive's entries.
+// carlos ship's own size refusal (RFS-1-C43) speaks for the built image;
+// these stop a decompression bomb before the spool fills the disk. Its own
+// text is never printed: each refusal is a tooLargeError with its sentence.
var errTooLarge = errors.New("rootfsimage: the image is too large to flatten")
+// tooLargeError is one of errTooLarge's refusals, its text the sentence.
+type tooLargeError struct{ msg string }
+
+func (e tooLargeError) Error() string { return e.msg }
+func (tooLargeError) Is(target error) bool { return target == errTooLarge }
+
+// tooManyEntriesError: a tree of more than n entries (RFS-1-C91).
+func tooManyEntriesError(n int) error {
+ return tooLargeError{fmt.Sprintf("the image has more than %d entries, so it was not shipped", n)}
+}
+
+// layersTooLargeError: layers that decode to more bytes than the bound
+// (RFS-1-C92).
+func layersTooLargeError() error {
+ return tooLargeError{"the image's layers are too large, so it was not shipped"}
+}
+
+// filesTooLargeError: files that pass n bytes in all (RFS-1-C93).
+func filesTooLargeError(n int64) error {
+ return tooLargeError{fmt.Sprintf("the image's files are over %d bytes, so it was not shipped", n)}
+}
+
+// tooManyIndexEntriesError: indexes listing more than n descriptors
+// (RFS-1-C94).
+func tooManyIndexEntriesError(n int) error {
+ return tooLargeError{fmt.Sprintf("the image lists more than %d index entries, so it was not shipped", n)}
+}
+
+// blobTooLargeError: a blob of size bytes, over max (RFS-1-C95).
+func blobTooLargeError(digest string, size, max int64) error {
+ return tooLargeError{fmt.Sprintf("the image data %s is %d bytes, over the %d-byte limit", shown(digest), size, max)}
+}
+
+// jsonTooLargeError: a layout or archive file of size bytes, over max
+// (RFS-1-C96).
+func jsonTooLargeError(name string, size, max int64) error {
+ return tooLargeError{fmt.Sprintf("the image file %s is %d bytes, over the %d-byte limit", shown(name), size, max)}
+}
+
+// archiveTooManyEntriesError: an archive of more than n entries
+// (RFS-1-C97).
+func archiveTooManyEntriesError(n int) error {
+ return tooLargeError{fmt.Sprintf("the archive has more than %d entries, so it was not shipped", n)}
+}
+
// shown is s as the sentences print it: as it is when every rune is
// printable, else quoted, so a name in an image cannot drive the terminal.
func shown(s string) string {
diff --git a/internal/rootfsimage/flatten.go b/internal/rootfsimage/flatten.go
index ffcacf009..7b9ae4268 100644
--- a/internal/rootfsimage/flatten.go
+++ b/internal/rootfsimage/flatten.go
@@ -27,8 +27,8 @@ const (
// files, sockets and pipes are counted in Skipped and left out.
//
// A name that leaves the root is UnsafePathError, a hardlink to anything but
-// a regular file of the tree, or a path needing more than 40 symlinks, is
-// LinkError. On an error the tree is part-applied and must be discarded.
+// a regular file of the tree is LinkError, and a path needing more than 40
+// symlinks is SymlinkChainError. On an error the tree is part-applied and must be discarded.
func (t *Tree) ApplyLayer(r io.Reader) error {
// The decoded stream is bounded as well as the files, so a layer of
// skipped entries cannot be an endless decompression either.
@@ -48,7 +48,7 @@ func (t *Tree) ApplyLayer(r io.Reader) error {
}
t.headers++
if t.headers > t.maxHeaders {
- return fmt.Errorf("%w: it has more than %d entries", errTooLarge, t.maxHeaders)
+ return tooManyEntriesError(t.maxHeaders)
}
if err := t.applyEntry(hdr, tr, added); err != nil {
return err
@@ -259,7 +259,7 @@ type boundedReader struct {
func (b *boundedReader) Read(p []byte) (int, error) {
if b.n <= 0 {
- return 0, fmt.Errorf("%w: its layers decode to too many bytes", errTooLarge)
+ return 0, layersTooLargeError()
}
if int64(len(p)) > b.n {
p = p[:b.n]
diff --git a/internal/rootfsimage/flatten_test.go b/internal/rootfsimage/flatten_test.go
index c8c28c7c4..3df06084f 100644
--- a/internal/rootfsimage/flatten_test.go
+++ b/internal/rootfsimage/flatten_test.go
@@ -268,9 +268,9 @@ func TestFlattenFollowsASymlinkedParentInsideTheRoot(t *testing.T) {
long := NewTree(t.TempDir())
apply(t, long, layerTar(t, chain...))
err := long.ApplyLayer(bytes.NewReader(layerTar(t, tEntry{name: "l0/x", typ: tar.TypeReg, mode: 0o644, body: "x"})))
- var le LinkError
- if !errors.As(err, &le) || le.Path == "" {
- t.Fatalf("41 links: err = %v, want LinkError", err)
+ var se SymlinkChainError
+ if !errors.As(err, &se) || se.Path != "l0" {
+ t.Fatalf("41 links: err = %v, want SymlinkChainError{l0}", err)
}
if _, ok := long.Resolve("l0/x"); ok {
t.Error("Resolve followed 41 links")
@@ -285,8 +285,8 @@ func TestFlattenFollowsASymlinkedParentInsideTheRoot(t *testing.T) {
tEntry{name: "a", typ: tar.TypeSymlink, link: "b"},
tEntry{name: "b", typ: tar.TypeSymlink, link: "a"},
))
- if err := loop.ApplyLayer(bytes.NewReader(layerTar(t, tEntry{name: "a/x", typ: tar.TypeReg}))); !errors.As(err, &le) {
- t.Fatalf("loop: err = %v, want LinkError", err)
+ if err := loop.ApplyLayer(bytes.NewReader(layerTar(t, tEntry{name: "a/x", typ: tar.TypeReg}))); !errors.As(err, &se) {
+ t.Fatalf("loop: err = %v, want SymlinkChainError", err)
}
}
diff --git a/internal/rootfsimage/oci.go b/internal/rootfsimage/oci.go
index 5ea3bc714..3db77dd46 100644
--- a/internal/rootfsimage/oci.go
+++ b/internal/rootfsimage/oci.go
@@ -203,7 +203,7 @@ func (o *ociReader) collect(idx index, depth int) error {
for _, d := range idx.Manifests {
o.descriptors++
if o.descriptors > maxDescriptors {
- return fmt.Errorf("%w: its indexes list more than %d entries", errTooLarge, maxDescriptors)
+ return tooManyIndexEntriesError(maxDescriptors)
}
if isAttestation(d) || o.seen[d.Digest] {
continue
@@ -342,7 +342,7 @@ func (o *ociReader) blob(d descriptor, max int64) (*verifier, error) {
return nil, fmt.Errorf("rootfsimage: %s: blob %s has a negative size", shown(o.source), d.Digest)
}
if max >= 0 && d.Size > max {
- return nil, fmt.Errorf("%w: blob %s is %d bytes, over %d", errTooLarge, d.Digest, d.Size, max)
+ return nil, blobTooLargeError(d.Digest, d.Size, max)
}
rc, size, err := o.st.open("blobs/sha256/" + m[1])
if err != nil {
@@ -439,7 +439,7 @@ func readFile(st blobStore, name string) ([]byte, error) {
}
defer rc.Close()
if size > maxJSONBytes {
- return nil, fmt.Errorf("%w: %s is %d bytes, over %d", errTooLarge, name, size, maxJSONBytes)
+ return nil, jsonTooLargeError(name, size, maxJSONBytes)
}
return io.ReadAll(io.LimitReader(rc, size))
}
diff --git a/internal/rootfsimage/oci_test.go b/internal/rootfsimage/oci_test.go
index be725aa55..687fd690d 100644
--- a/internal/rootfsimage/oci_test.go
+++ b/internal/rootfsimage/oci_test.go
@@ -320,14 +320,26 @@ func TestErrorsSpeakTheDeck(t *testing.T) {
{NotAnImageError{"shop.tar"}, "shop.tar is not an OCI image, a docker save archive or a directory"},
{UnsafePathError{"../x"}, "the image has an entry outside its root (../x), so it was not shipped"},
{LinkError{"usr/bin/ruby2", "usr/bin/ruby"}, "the image links usr/bin/ruby2 to usr/bin/ruby, which is not a file in the image"},
- {ManyImagesError{2}, "the archive holds 2 images. save one image and ship that"},
- {DigestError{"sha256:ab"}, "the image's layer sha256:ab does not match its digest, so it was not shipped"},
+ {ManyImagesError{2}, "the source holds 2 images. keep one image and ship that"},
+ {DigestError{"sha256:ab"}, "the image data sha256:ab does not match its digest, so it was not shipped"},
+ {SymlinkChainError{"l0/x"}, "the image path l0/x follows more than 40 symlinks, so it was not shipped"},
+ {tooManyEntriesError(5), "the image has more than 5 entries, so it was not shipped"},
+ {layersTooLargeError(), "the image's layers are too large, so it was not shipped"},
+ {filesTooLargeError(7), "the image's files are over 7 bytes, so it was not shipped"},
+ {tooManyIndexEntriesError(1024), "the image lists more than 1024 index entries, so it was not shipped"},
+ {blobTooLargeError("sha256:ab", 9, 8), "the image data sha256:ab is 9 bytes, over the 8-byte limit"},
+ {jsonTooLargeError("index.json", 9, 8), "the image file index.json is 9 bytes, over the 8-byte limit"},
+ {archiveTooManyEntriesError(3), "the archive has more than 3 entries, so it was not shipped"},
// A name with control bytes is quoted, so it cannot drive the terminal.
{UnsafePathError{"../\x1b[2Jx"}, `the image has an entry outside its root ("../\x1b[2Jx"), so it was not shipped`},
} {
if got := c.err.Error(); got != c.want {
t.Errorf("%T: %q\nwant %q", c.err, got, c.want)
}
+ // The size refusals are still errTooLarge to their callers.
+ if _, ok := c.err.(tooLargeError); ok && !errors.Is(c.err, errTooLarge) {
+ t.Errorf("%q is not errTooLarge", c.err)
+ }
}
}
diff --git a/internal/rootfsimage/tree.go b/internal/rootfsimage/tree.go
index 7dababd0d..454ae65f0 100644
--- a/internal/rootfsimage/tree.go
+++ b/internal/rootfsimage/tree.go
@@ -195,7 +195,7 @@ func (t *Tree) Files() int {
}
// resolve is Resolve with the error for a resolution past maxLinks
-// (LinkError naming the symlink it stopped at) and the choice of following
+// (SymlinkChainError naming the path) and the choice of following
// a symlink in the last component. The path it answers is "" for the root.
// A component that is missing, or that is a file with more components after
// it, ends the walk's knowledge: the rest is joined lexically (still clamped
@@ -225,7 +225,7 @@ func (t *Tree) resolve(p string, followLast bool) (string, bool, error) {
case e.Type == TypeSymlink && (len(comps) > 0 || followLast):
links++
if links > maxLinks {
- return next, false, LinkError{Path: next, Target: e.LinkTarget}
+ return next, false, SymlinkChainError{Path: dotRoot(cleanRel(p))}
}
if strings.HasPrefix(e.LinkTarget, "/") {
cur = ""
@@ -272,7 +272,7 @@ func (t *Tree) remove(p string) {
// counter and never by anything the image says, and answers its path.
func (t *Tree) writeBlob(r io.Reader, n int64) (string, error) {
if n < 0 || n > t.maxBytes-t.usedBytes {
- return "", fmt.Errorf("%w: its files pass %d bytes", errTooLarge, t.maxBytes)
+ return "", filesTooLargeError(t.maxBytes)
}
t.usedBytes += n
t.blobs++
diff --git a/ship_runtime.go b/ship_runtime.go
index 07f042dae..24a987127 100644
--- a/ship_runtime.go
+++ b/ship_runtime.go
@@ -48,7 +48,7 @@ func findContainerRuntime() (containerRuntime, error) {
names := []string{"docker", "podman"}
if v := os.Getenv("CARLOS_CONTAINER_RUNTIME"); v != "" {
if v != "docker" && v != "podman" {
- return containerRuntime{}, errors.New("CARLOS_CONTAINER_RUNTIME must be docker or podman")
+ return containerRuntime{}, errors.New("the CARLOS_CONTAINER_RUNTIME value must be docker or podman")
}
names = []string{v}
}
diff --git a/ship_runtime_test.go b/ship_runtime_test.go
index 673ce8a46..34efb1626 100644
--- a/ship_runtime_test.go
+++ b/ship_runtime_test.go
@@ -78,7 +78,7 @@ func TestFindContainerRuntime(t *testing.T) {
}
t.Setenv("CARLOS_CONTAINER_RUNTIME", "nerdctl")
- if _, err = findContainerRuntime(); err == nil || err.Error() != "CARLOS_CONTAINER_RUNTIME must be docker or podman" {
+ if _, err = findContainerRuntime(); err == nil || err.Error() != "the CARLOS_CONTAINER_RUNTIME value must be docker or podman" {
t.Fatalf("nerdctl: err = %v", err)
}
}