Problem description
The first hard link link2symlink fakes for a file moves it to
<l2s dir>/.l2s.<name>NNNN.0002, NNNN being the first free four-digit suffix
for that name. The search stops at 999 (move_and_symlink_path()):
do {
sprintf(new_intermediate, "%s%04d", intermediate, intermediate_suffix);
intermediate_suffix++;
} while ((l2s_access(new_intermediate) != -1) && (intermediate_suffix < 1000));
When every suffix up to 0999 is taken, the loop ends on 0999, which is
in use, and the function carries on with it anyway:
l2s_rename(original, ".l2s.<name>0999.0002") moves the tracee's file
away, silently replacing that final file if it exists (it does whenever
the file using 0999 currently has two links, e.g. while dpkg holds its
backup).
l2s_symlink(final, ".l2s.<name>0999") fails with EEXIST.
- The error is returned as-is. The
l2s_* helpers return -1 with errno
set, so the tracee gets -1, i.e. EPERM, instead of the real error.
- Nothing moves the file back, so the file is gone from where the
tracee left it.
The same thing happens before the limit, too:
- A dangling intermediate counts as free, because
access() follows it.
Creating the symlink over it then fails the same way and the file is lost.
- A final file left without its intermediate counts as free and is
silently replaced by the rename.
proot-distro pins PROOT_L2S_DIR to a single <rootfs>/.l2s, so every file
with the same name anywhere in the rootfs shares those 999 suffixes. dpkg
hard-links each file it replaces as a backup, so a Python-heavy system runs
out of __init__.py suffixes quickly (google-cloud-cli alone ships over
4,000). From then on every dpkg upgrade that replaces an __init__.py fails,
and each failed run deletes one file:
Unpacking google-cloud-cli (584.0.0-0) over (582.0.0-0) ...
dpkg: error processing archive .../google-cloud-cli_584.0.0-0_arm64.deb (--unpack):
unable to make backup link of './usr/lib/google-cloud-sdk/lib/googlecloudsdk/command_lib/compute/network_policies/__init__.py' before installing new version: Operation not permitted
That system had exactly 999 .l2s.__init__.py0001...0999 entries in its
/.l2s. Five retries left five __init__.py files missing, one per run.
Steps to reproduce
From a plain Termux shell (no distro needed):
D=$TMPDIR/l2s-demo
rm -rf $D && mkdir -p $D/l2s $D/w
seq -f "$D/l2s/.l2s.__init__.py%04g" 1 999 | xargs touch # use up the suffixes
echo data > $D/w/__init__.py
PROOT_L2S_DIR=$D/l2s proot -l ln $D/w/__init__.py $D/w/backup
cat $D/w/__init__.py
ls -A $D/l2s | tail -n 2
Result:
ln: failed to access '.../w/__init__.py': No such file or directory
cat: .../w/__init__.py: No such file or directory
.l2s.__init__.py0999
.l2s.__init__.py0999.0002 <- the file's content ended up here, unreferenced
Without the pre-filled directory, a single dangling intermediate is enough:
D=$TMPDIR/l2s-demo2
rm -rf $D && mkdir -p $D && cd $D
echo data > original
ln -s "$D/.l2s.original0001.0002" .l2s.original0001 # dangling intermediate
proot -l ln original link
cat original # No such file or directory
Expected behavior
link() either succeeds or fails with a meaningful errno (EMLINK when no
suffix is left), and in both cases the original file stays where it was and
no other file's content is replaced.
Additional information
- proot 5.1.107-71 (Termux package). Reproduced on current master (7266fb3)
built from source; the code in question is unchanged there.
- proot-distro 5.0.2, Debian 13 (trixie) aarch64 rootfs
- Samsung SM-N975U, Android 10, unrooted
Packages CPU architecture: arm64
termux-tools version: 1.45.0
Android version: 10
Device manufacturer: samsung
Device model: SM-N975U
Supported ABIs: arm64-v8a,armeabi-v7a,armeabi
Problem description
The first hard link link2symlink fakes for a file moves it to
<l2s dir>/.l2s.<name>NNNN.0002, NNNN being the first free four-digit suffixfor that name. The search stops at 999 (
move_and_symlink_path()):When every suffix up to 0999 is taken, the loop ends on
0999, which isin use, and the function carries on with it anyway:
l2s_rename(original, ".l2s.<name>0999.0002")moves the tracee's fileaway, silently replacing that final file if it exists (it does whenever
the file using 0999 currently has two links, e.g. while dpkg holds its
backup).
l2s_symlink(final, ".l2s.<name>0999")fails with EEXIST.l2s_*helpers return-1with errnoset, so the tracee gets
-1, i.e. EPERM, instead of the real error.tracee left it.
The same thing happens before the limit, too:
access()follows it.Creating the symlink over it then fails the same way and the file is lost.
silently replaced by the rename.
proot-distro pins
PROOT_L2S_DIRto a single<rootfs>/.l2s, so every filewith the same name anywhere in the rootfs shares those 999 suffixes. dpkg
hard-links each file it replaces as a backup, so a Python-heavy system runs
out of
__init__.pysuffixes quickly (google-cloud-clialone ships over4,000). From then on every dpkg upgrade that replaces an
__init__.pyfails,and each failed run deletes one file:
That system had exactly 999
.l2s.__init__.py0001...0999entries in its/.l2s. Five retries left five__init__.pyfiles missing, one per run.Steps to reproduce
From a plain Termux shell (no distro needed):
Result:
Without the pre-filled directory, a single dangling intermediate is enough:
Expected behavior
link()either succeeds or fails with a meaningful errno (EMLINK when nosuffix is left), and in both cases the original file stays where it was and
no other file's content is replaced.
Additional information
built from source; the code in question is unchanged there.