Repository: systemd/mkosi

Issue:
Copying in trees from build script fails with overwriting symlinks
This is the same issue as https://github.com/systemd/mkosi/issues/166 but as it reappeared, I thought I'd open a new bug report.

I'm trying to build `sbctl` in an mkosi container. The output provides `/usr/local/share/man/man8/sbctl.8`, which is outputted into `DESTDIR` in `mkosi.build.chroot` in my case. `mkosi` then tries to install that file to the regular tree, which fails:

```
+ tree /work/dest
/work/dest
├── local_scratch
│   └── aur_packages
│       ├── pacman-hook-kernel-install.pkg.tar.zst
│       └── paru.pkg.tar.zst
└── usr
    └── local
        ├── bin
        │   └── sbctl
        ├── lib
        │   └── kernel
        │       └── install.d
        │           └── 91-sbctl.install
        └── share
            ├── bash-completion
            │   └── completions
            │       └── sbctl
            ├── fish
            │   └── vendor_completions.d
            │       └── sbctl.fish
            ├── licenses
            │   └── sbctl
            │       └── LICENSE
            ├── man
            │   └── man8
            │       └── sbctl.8
            └── zsh
                └── site-functions
                    └── _sbctl

20 directories, 9 files
+ tree //usr/local
//usr/local
├── bin
├── etc
├── games
├── include
├── lib
├── man
├── sbin
├── share
│   └── man -> ../man
└── src

11 directories, 0 files
‣  Copying in build tree…
cp: cannot overwrite non-directory '/home/cnhof/.cache/mkosi-workspace62czz5gn/root/usr/local/share/man' with directory '/home/cnhof/.cache/mkosi-workspace62czz5gn/dest/usr/local/share/man'
‣ "cp --recursive --no-dereference --preserve=mode,timestamps,links,xattr,ownership --reflink=auto /home/cnhof/.cache/mkosi-workspace62czz5gn/dest /home/cnhof/.cache/mkosi-workspace62czz5gn/root --no-target-directory" returned non-zero exit code 1.
```


Discussion:
Yes this is a known issue. Unfortunately `cp` just can't handle this. And `rsync` doesn't support reflinks, so whatever we use will be bad in some form.

Ideally someone would implement `--keep-directory-symlink` for `cp`.
> Ideally someone would implement --keep-directory-symlink for cp.

Just submitted a patch, will link it here once it appears in the archives. This won't fix your issue but it will at least provide a future solution if accepted.
https://lists.gnu.org/archive/html/bug-coreutils/2023-12/msg00022.html
Just to confirm: This works for me, when using an extra search path for `cp` and
```
diff --git a/mkosi/tree.py b/mkosi/tree.py
index 014f130..27d3ef1 100644
--- a/mkosi/tree.py
+++ b/mkosi/tree.py
@@ -68,6 +68,8 @@ def copy_tree(
         "--no-dereference",
         f"--preserve=mode,timestamps,links,xattr{',ownership' if preserve_owner else ''}",
         "--reflink=auto",
+        "--copy-contents",
+        "--keep-directory-symlink",
         src, dst,
     ]
```

Resolving patch:
diff --git a/mkosi/sandbox.py b/mkosi/sandbox.py
--- a/mkosi/sandbox.py
+++ b/mkosi/sandbox.py
@@ -203,6 +203,8 @@ def apivfs_cmd(root: Path) -> list[PathString]:
         "--dev", root / "dev",
         # Make sure /etc/machine-id is not overwritten by any package manager post install scripts.
         "--ro-bind-try", root / "etc/machine-id", root / "etc/machine-id",
+        # Nudge gpg to create its sockets in /run by making sure /run/user/0 exists.
+        "--dir", root / "run/user/0",
         *finalize_passwd_mounts(root),
         "sh", "-c",
         f"chmod 1777 {root / 'tmp'} {root / 'var/tmp'} {root / 'dev/shm'} && "
diff --git a/mkosi/tree.py b/mkosi/tree.py
--- a/mkosi/tree.py
+++ b/mkosi/tree.py
@@ -14,6 +14,7 @@
 from mkosi.sandbox import SandboxProtocol, nosandbox
 from mkosi.types import PathString
 from mkosi.util import flatten
+from mkosi.versioncomp import GenericVersion
 
 
 def statfs(path: Path, *, sandbox: SandboxProtocol = nosandbox) -> str:
@@ -25,6 +26,10 @@ def is_subvolume(path: Path, *, sandbox: SandboxProtocol = nosandbox) -> bool:
     return path.is_dir() and statfs(path, sandbox=sandbox) == "btrfs" and path.stat().st_ino == 256
 
 
+def cp_version() -> GenericVersion:
+    return GenericVersion(run(["cp", "--version"], stdout=subprocess.PIPE).stdout.splitlines()[0].split()[3])
+
+
 def make_tree(
     path: Path,
     *,
@@ -92,8 +97,12 @@ def copy_tree(
         "--dereference" if dereference else "--no-dereference",
         f"--preserve=mode,links{',timestamps,ownership,xattr' if preserve else ''}",
         "--reflink=auto",
+        "--copy-contents",
         src, dst,
     ]
+    if cp_version() >= "9.5":
+        copy += ["--keep-directory-symlink"]
+
     options: list[PathString] = ["--ro-bind", src, src, "--bind", dst.parent, dst.parent]
 
     # If the source and destination are both directories, we want to merge the source directory with the


Test patch: