Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

contenthash: implement proper Linux symlink semantics #4896

Open
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

cyphar
Copy link
Contributor

@cyphar cyphar commented May 2, 2024

This series fixes the symlink following implementation of cache/contenthash. The primary issue with the previous implementation is that it assumed that path.Join is a reasonable way of implementing trailing symlink following -- this is not correct on Linux. path.Clean is a Plan9-ism, and while it is useful, on Linux symlinks are resolved left-to-right and thus lexically transforming a/b/../c to a/c is incorrect if b is a symlink. The paper describing Plan9's path_clean makes reference to this Unix-ism they fixed by removing symlinks from Plan9.

As the trailing symlink logic was written in quite a few places, all of the implementations needed to be fixed. The headline changes are:

  • All of the symlink following implementations are now iterative, based on https://github.com/cyphar/filepath-securejoin. Because of the requirement to apply each component in a symlink individually, recursive implementations end up with a lot of wasted work.
  • The follow flag now means followTrailing (O_NOFOLLOW) everywhere. Previously, getFollowLinks and cc.checksum used it to mean "don't follow any links" (a-la RESOLVE_NO_SYMLINKS) while cc.Checksum and cc.checksumFollow used it to mean O_FOLLOW. There was only one user of the RESOLVE_NO_SYMLINKS implementation (cc.includedPaths) and it appears it was only used as an optimisation (when in fact, since we are iterating over the keys in the cache, the key definitely exists, cc.checksum will just do a simple lookup regardless of RESOLVE_NO_SYMLINKS).
  • cc.checksumFollow is gone (now that checksum supports followTrailing directly), and cc.checksumNoFollow and cc.lazyChecksum have been renamed and reworked slightly. Removing the loop in checksumFollow also means we will detect symlink loops 255 times faster 😸, not to mention that we now don't do a bunch of unnecessary scanning and checksumming when dealing with trailing symlinks.
  • needsScan now just uses getFollowLinksCallback to track the status of a lookup to see if there is a non-symlink path component that has already been scanned in the cache. This removes one extra implementation of symlink lookups.
  • scanPath now does a scan of path.Dir of the resolved path, rather than resolving the lexical directory of the requested path (this means scanPath, needsScan, and thus rootPath now also take followTrailing). I suspect that always fully-resolving the path would be okay (because we fill in symlinks during resolution) but in the case of followTrailing=false it seems unclear to me which directory "should" be scanned. The old implementation would scan all of them (because checksumFollow would re-apply the trailing link and re-checksum everything).

Signed-off-by: Aleksa Sarai cyphar@cyphar.com

@cyphar cyphar force-pushed the contenthash-symlink-resolution branch from 13c27ce to 98280ea Compare May 2, 2024 12:38
@cyphar cyphar marked this pull request as ready for review May 2, 2024 12:38
@cyphar
Copy link
Contributor Author

cyphar commented May 2, 2024

For reference, here is a Dockerfile which reliably triggered this issue:

FROM alpine:3.19 AS base
USER root
RUN mkdir -p /target/dir /links1 /links2 ; \
	touch /target/dir/foo ; \
	ln -s ../target/dir  /links1/dir_rel ; \
	ln -s /target/dir    /links1/dir_abs ; \
	ln -s ../target  /links1/target_rel ; \
	ln -s /target    /links1/target_abs ; \
	ln -s ../links1/ /links2/links1_rel ; \
	ln -s /links1/ /links2/links1_abs

FROM scratch as test_0
COPY --from=base /target/dir /data/0
COPY --from=base /links1/dir_rel /data/1
COPY --from=base /links1/dir_abs /data/2
COPY --from=base /links1/target_rel/dir /data/3
COPY --from=base /links1/target_abs/dir /data/4
COPY --from=base /links2/links1_rel/dir_abs /data/5
COPY --from=base /links2/links1_abs/dir_abs /data/6
COPY --from=base /links2/links1_rel/target_abs/dir /data/7
COPY --from=base /links2/links1_abs/target_abs/dir /data/8

# The following COPY commands fail.
FROM scratch as test_1
COPY --from=base /links2/links1_rel/dir_rel /data/9
COPY --from=base /links2/links1_abs/dir_rel /data/a
COPY --from=base /links2/links1_rel/target_rel/dir /data/b
COPY --from=base /links2/links1_abs/target_rel/dir /data/c

@cyphar cyphar force-pushed the contenthash-symlink-resolution branch 2 times, most recently from 377fd17 to 95995f5 Compare May 2, 2024 15:16
@cyphar
Copy link
Contributor Author

cyphar commented May 2, 2024

The getFollowParentLinks thing that exists in one patch and is removed in another is a bit unfortunate but I don't see a nice way of splitting the fixes to getFollowLinks and the followTrailing logic without making one mega patch. If you'd prefer that, let me know and I'll squash things down.

@tonistiigi tonistiigi added this to the v0.14.0 milestone May 2, 2024
@cyphar cyphar force-pushed the contenthash-symlink-resolution branch 4 times, most recently from 7f3d018 to 4692cf0 Compare May 4, 2024 06:08
cyphar added a commit to SUSE/docker that referenced this pull request May 6, 2024
cyphar added a commit to SUSE/docker that referenced this pull request May 6, 2024
cyphar added a commit to SUSE/docker that referenced this pull request May 6, 2024
cyphar added a commit to SUSE/docker that referenced this pull request May 6, 2024
cyphar added a commit to SUSE/docker that referenced this pull request May 6, 2024
@cyphar cyphar force-pushed the contenthash-symlink-resolution branch from 4692cf0 to 93980ea Compare May 6, 2024 18:45
@cyphar
Copy link
Contributor Author

cyphar commented May 6, 2024

Maybe I should get a dummy PR merged so you don't need to re-trigger the CI 😅.

@jedevc
Copy link
Member

jedevc commented May 7, 2024

Not gotten a chance to dive into this yet, but just a quick comment - I wonder if we need any changes in https://github.com/tonistiigi/fsutil as well? There's some logic there for following symlinks as well, and other path resolution-y things.

@cyphar
Copy link
Contributor Author

cyphar commented May 7, 2024

Yeah, I looked at fsutil earlier and it does have bugs with symlink resolution (in short, anything that does filepath.Join(foo, bar.Linkname) or filepath.Clean(bar.Linkname) probably has bugs).

However, it wasn't clear to me how it's used by buildkit/docker/containerd. I can write some patches, but if they have subtle semantics (like getFollowLinks does in buildkit) it might be difficult to figure out whether or not I'm breaking something.

(It's a little frustrating that I spent so much time getting RESOLVE_IN_ROOT merged but we can't use it. I really need to pick up the leftover stuff in https://github.com/openSUSE/libpathrs...)

cyphar added 5 commits May 8, 2024 01:06
This is based on the github.com/cyphar/filepath-securejoin
implementation. Since we need to resolve all components when doing
lookups, doing it recursively serves little purpose (other than
complicating the implementation).

Ref: 9bf5431 ("contenthash: fix issues on symlinks on parent paths")
Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>
This patch is part of a series which fixes the symlink resolution
semantics within BuildKit.

You cannot implement symlink resolution on Linux naively using
path.Join. A correct implementation requires tracking the current path
and applying each new component individually. This implementation is
loosely based on github.com/cyphar/filepath-securejoin.

Things to note:

 * The previous implementation of getFollowLinks actually only resolved
   symlinks in parent components of the path, leading to some callers to
   have to implement resolution manually (and incorrectly) by calling
   getFollowLinks several times. In addition to being incorrect and
   somewhat difficult to follow, it also lead to the ELOOP limit being
   much higher than 255 (while some callers used getFollowLinksWalk,
   most used getFollowLinks which reset the limit for each iteration).

   So, add getFollowParentLinks to allow for callers to decide which
   behaviour they need. getFollowLinks now follows all links
   (correctly).

 * The trailing-slash-is-significant behaviour in the cache (dir vs
   dir header) needs to be handled specially because on Linux there is
   no distinction between "a/" and "a" (assuming a is not a symlink,
   that is) and so filepath-securejoin's implementation didn't care
   about trailing slashes. The previous implementation hid the trailing
   path behaviour purely in the splitKey() implementation, making the
   need for this quite subtle.

 * The previous implementation was recursive, which in theory would
   allow you to find some paths slightly more quickly (if you find a
   valid ancestor you don't need to check above it) at the cost of
   making some lookups more expensive (a path with an invalid ancestor
   very early on in the path).

   However, implementing the correct lookup algorithm recursively proved
   to be quite difficult. It is possible to implement a similar
   optimisation (try to find the first non-symlink parent component and
   iterate from there), this complicates the implementation a fair
   amount and it doesn't seem clear that the performance tradeoff is a
   benefit in general.

   Ultimately, cache lookups are quite fast and so there probably isn't
   a practical performance difference between approaches.

Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>
This patch is part of a series which fixes the symlink resolution
semantics within BuildKit.

Since we now have a working implementation of symlink resolution in
getFollowLinks, we can add a callback after each component is resolved
so that we can track which components were and were not found during the
resolution. Because getFollowLinks resolves the components in-order, we
can use this to tell needsScan whether the resolver found a real
(non-symlink) ancestor of the requested path in the cache.

Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>
This patch is part of a series which fixes the symlink resolution
semantics within BuildKit.

Previously, the concept of the follow flag had different meanings in
various parts of the checksum codepath. FollowLinks is effectively
O_NOFOLLOW, but the implementation in getFollowLinks was actually more
like RESOLVE_NO_SYMLINKS. This was masked by the fact that
checksumFollow would implement the O_NOFOLLOW behaviour (incorrectly),
but checksumFollow would call checksumNoFollow (which would follow
symlinks in path components by setting follow=true for getFollowLinks).

It is much easier to simply remove these layers of indirection and unify
the meaning of FollowLinks across all of the code. This means that the
old follow flag is no longer needed.

This also means that we can now remove the incorrect symlink resolution
logic in (*cacheContext).checksumFollow() and move the followTrailing
logic to (*cacheContext).checksum(), as well as removing
getFollowParentLinks(). Since this removes some redundant re-checksum
loops, we need to add followTrailing logic to scanPath() so that final
symlink components result in the correct directory being scanned
properly.

The only user of (*cacheContext).checksum(follow=false) was
(*cacheContext).includedPaths() which appeared to be simply using this
as an optimisation (since the path being walked already had its parent
path resolved). Having two easily-confused boolean flags for an
optimisation that is probably not necessary (getFollowLinks already does
a fast check to see if the original path is in the cache) seemed
unnecessary, so just keep followTrailing.

Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>
Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>
@cyphar cyphar force-pushed the contenthash-symlink-resolution branch from 8ce3186 to f56db22 Compare May 7, 2024 15:07
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.

None yet

3 participants