Skip to content

dockivad: remove, copy and archive trees with 0555 and 000 directories - #36

Merged
chandarith-chea merged 3 commits into
mainfrom
dockivad-restricted-dirs
Nov 14, 2026
Merged

chandarith-chea merged 3 commits into
mainfrom
dockivad-restricted-dirs

Conversation

@chandarith-chea

@chandarith-chea chandarith-chea commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

The bug

The engine runs as the Mac user, not root, so a guest's chmod 555 or chmod 000 of a directory is enforced against dockivad too. Go's module cache is made of 0555 directories, and #34 lets guest root make more of them. With such a directory holding files (engine af13b2b):

command before this branch
docker rmi of an image with a non-empty 0555 directory unlinkat …: permission denied; image.json gone, files left; every later docker images answers Host operation failed image and files removed; docker images works
docker commit of a container with nested 0555 directories mkdir <store>/.commit-*/rootfs/root/a/d: permission denied image made, modes and files kept
docker cp c:/dir -, docker export with a 000 directory or file truncated tar whole tree, modes kept
docker build its work directory stayed in <store>/build when a stage held a 0555 directory (21 left on my installed 0.6.10) removed

The fix

dockivad/restricted.go holds the helpers. Where the engine removes, copies or archives guest files, the directory or file gets the owner bits it lacks for as long as they are needed and its own mode back after, so what is stored and exported keeps the guest's modes.

  • Removal. Every removal of guest files goes through removeTree. An image directory leaves the cache in one rename (.removing-*) before its files go: the image is listed whole or not at all, and what a failed removal leaves goes at the next start. A cache directory left without image.json by an earlier engine is skipped by docker images and docker rmi, and removed at start.

  • Copies. clonefile copies a tree whole or not at all (EACCES on one 000 entry), so cloneTree unlocks such a tree, clones, and locks source and copy again. applyUpper (commit) keeps the directories it fills writable and sets their modes last. Layer diffs, the build cache's hash and size and build exports walk with walkTree, and look up the base through 000 directories.

  • Archives. docker cp out, docker export and docker diff read 000 directories and files. docker cp into a container, the engine's own files and mount points, and a build's COPY write into a 0555 directory, as Docker's root does.

  • Leftovers. At a start, before the API serves requests, what an engine that has gone left behind is renamed aside and removed in the background: build, load and .commit-* in the store, and build and load in the home from before the store.

A running container sees the wider mode for the moment the engine holds it, as with the runtime's own override in #34.

Not changed

Checks

  • dockivad/restricted_test.go: docker rmi of an image with such directories and docker images beside a half-removed one; a commit's copy and its layer; docker cp out, docker export and docker cp in (these three fail on main); the start-up sweep of leftovers.
  • go vet ./... and go test ./... in dockivad/ pass.
  • scripts/verify_dockivad.py: 31/31 on both layouts (default and --new-home).
  • End to end on locally built bundles, temporary home, both with main's runtime and with Root changes directories and opens files whatever their mode (CAP_DAC_OVERRIDE) #34's (which is what writes files into 0555 and 000 directories): the rmi sequence then docker images; the commit, with the committed image run and its file read back; docker cp and docker export of a tree with a 000 directory and a 000 file; docker build FROM the committed image with a RUN and a COPY; docker cp into a 0555 directory; docker rm, docker rmi and docker builder prune leaving nothing behind; a half-removed image directory is listed around and gone after a restart; planted build, load and .commit-* leftovers with 0555 directories are gone after a restart, and a build after it works.

The engine is the Mac user, not root, so a guest's chmod 555 or chmod 000 of
a directory (Go's module cache is 0555) is enforced against the engine too.
With such a directory holding files:

- docker rmi removed image.json, then failed on the image's files ("unlinkat
  ...: permission denied"). The files stayed, and every docker images after
  it answered "Host operation failed" (lstat image.json).
- docker commit failed: "mkdir <store>/.commit-*/rootfs/...: permission
  denied". Builds left their work directories behind for the same reason.
- docker cp and docker export of a tree with a 000 directory or file sent a
  truncated tar.

restricted.go has what the engine now does everywhere it removes, copies or
archives guest files: the directory or file gets the owner bits it lacks for
as long as they are needed, and its own mode back after, so what is stored and
exported keeps the guest's modes.

- Removal: every removal of guest files is removeTree. An image's directory
  leaves the cache in one rename (.removing-*) before its files go, so the
  image is listed whole or not at all; what a failed removal leaves goes at
  the next start. A cache directory an earlier engine left without its
  image.json is skipped by docker images and docker rmi, and removed at start.
- Copies: cloneTree unlocks an unreadable tree for clonefile, which copies a
  tree whole or not at all (EACCES), and locks source and copy again.
  applyUpper (commit) keeps the directories it fills writable and sets their
  modes last. Layer diffs, the build cache's hash and size, and build exports
  walk with walkTree and read through 000 directories of the base.
- Archives: docker cp out, docker export and docker diff read 000 directories
  and files. docker cp into a container, the engine's own files and mount
  points, and a build's COPY write into a 0555 directory as Docker's root does.

Not changed: a path given to docker cp that itself runs through a 000
directory is still "not found"; unpacked image layers and populated volumes
keep the owner bits the engine adds; bind mounts are read as they are.

restricted_test.go: docker rmi of an image with such directories and docker
images beside a half-removed one; a commit's copy and its layer; docker cp
out, docker export and docker cp in. All three fail on main.
A build's work directory stayed in <store>/build whenever one of its stages
held a directory without write permission (os.RemoveAll in the background,
its error dropped): 21 were left on an installed 0.6.10, and 5 more in
<home>/build from before the store. The previous commit stops new ones;
nothing removed the old.

At a start, before the API serves anything but /_ping, staleWork renames
aside what an engine that has gone left in the store, and in the home before
it: build, load, .commit-* and what an earlier sweep did not finish
(.removing-*). They are removed in the background with removeTree, so a start
does not wait for gigabytes to go; what an interrupted removal leaves goes at
the next start.

TestStaleWorkRestrictedDirs: such leftovers with 0555 and 000 directories go,
images, containers, the build cache and volumes stay.
@chandarith-chea
chandarith-chea merged commit 09a51c9 into main Nov 14, 2026
2 checks passed
@chandarith-chea
chandarith-chea deleted the dockivad-restricted-dirs branch November 14, 2026 09:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant