Skip to content

Instantly share code, notes, and snippets.

@x-yuri
Last active June 6, 2023 22:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save x-yuri/aa6db295cd56f757537f756bc390b1df to your computer and use it in GitHub Desktop.
Save x-yuri/aa6db295cd56f757537f756bc390b1df to your computer and use it in GitHub Desktop.
Removing directories over CIFS/SMB

Removing directories over CIFS/SMB

With CIFS/SMB when you delete a dir, it might fail under certain conditions.

There are 2 aspects at play here. First, the approach. Let's take coreutils' rm. It does it this way:

dp = opendir(path);
if (dp == NULL) {
    return -1;
}

while ((d = readdir(dp)) != NULL) {
    char *new_path;

    new_path = concat_subpath_file(path, d->d_name);
    if (new_path == NULL)
        continue;
    if (remove_file(new_path, flags) < 0)
        status = -1;
    free(new_path);
}
closedir(dp);

Apparently the reading is buffered. And if there are many files, they're obtained in batches. Let's say it deleted the first bunch, and asks for more. Then what it gets is the third batch (in case of CIFS/SMB). Because after deleting the first batch, the second becomes the first, the third the second. At the end it tries to delete the directory, thinking it's empty. But it's not, and gets an error:

$ rm -r mnt/b
rm: can't remove 'mnt/b': Directory not empty

coreutils's rm uses fts. And from what I can see it has its own buffer. Or rather it first loads the list of the files into memory, then removes them. But it's not like it loads them all no matter what. busybox fails with 676 files. In case of coreutils with 17576 files it still works, but fails with 456976 files.

Then there's the second aspect. The buffer size. Let's take mc. Under Alpine Linux it fails to delete a dir with 676 files (at some point it says, "Directory not empty," and I have to skip). Under Debian though it succeeds. Which suggests that glibc's buffer is bigger then musl's. And indeed, after compiling musl with a bigger buffer it starts to work. pgbackrest also suffers from this issue.

Apparently readdir() doesn't provide any guarantees that are relevant here. Supposedly that is owing to the lack of such provisions in CIFS/SMB. As such, I'd say the best place to deal with it is as close to the user as possible. Where it might be known what is being deleted, and if other processes can write to the dir.

Supposedly that is owing to the lack of such provisions in CIFS/SMB.

Okay, that's a speculation on my part. Because with ordinary directories it works. But I don't think they made it work with CIFS/SMB this way on purpose. And with network I'd expect more issues.

On a side note, when I examined coreutils's rm (debug statements), I saw it deleting some funny files like AHY9U3~9.

docker-compose.yml:

services:
  samba:
    image: dperson/samba
    command: -u 'root;badpass' -s 'a;/a;no;no' -p
    volumes:
      - ./a.sh:/a.sh
  alpine:
    build: .
    command: sleep infinity
    init: yes
    privileged: yes
  debian:
    build:
      context: .
      dockerfile: Dockerfile-debian
    command: sleep infinity
    init: yes
    privileged: yes

Dockerfile:

FROM alpine:3.16
RUN apk add cifs-utils mc

Dockerfile-debian:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y cifs-utils mc

a.sh:

print_fnames() {
    for n in `seq 0 675`; do
    # for n in `seq 0 17575`; do
    # for n in `seq 0 456975`; do
        digits=`echo "obase=26; $n" | bc`
        s=$(for d in $digits; do
            h=`echo "obase=16; $d + 97" | bc`
            printf '%s' "$h"
        done | xxd -p -r)
        printf '%2s\n' "$s" | tr ' ' a
    done
}
apk add xxd
mkdir a/b
print_fnames | while IFS= read -r fname; do
    echo -- $fname
    touch "a/b/$fname"
done
chown -R smbuser:smb a
$ docker compose up

Alpine Linux:

$ docker compose exec alpine mount.cifs -o user=root,pass=badpass //samba/a /mnt
$ docker compose exec samba sh ./a.sh
$ docker compose exec alpine sh -c 'find mnt/b -type f | wc -l'
676
$ docker compose exec alpine rm -r mnt/b
rm: can't remove 'mnt/b': Directory not empty
$ docker compose exec alpine sh -c 'find mnt/b -type f | wc -l'
334
$ docker compose exec alpine rm -r mnt/b
rm: can't remove 'mnt/b': Directory not empty
$ docker compose exec alpine sh -c 'find mnt/b -type f | wc -l'
166
$ docker compose exec alpine rm -r mnt/b
rm: can't remove 'mnt/b': Directory not empty
$ docker compose exec alpine sh -c 'find mnt/b -type f | wc -l'
83
$ docker compose exec alpine rm -r mnt/b

Debian:

$ docker compose exec debian mount.cifs -o user=root,pass=badpass //samba/a /mnt
$ docker compose exec samba sh ./a.sh
$ docker compose exec debian rm -r mnt/b

Increase the musl's buffer:

Dockerfile:

FROM alpine:3.16 as musl
RUN set -x && apk add build-base curl gpg gnupg-dirmngr gpg-agent \
    && curl https://musl.libc.org/releases/musl-1.2.3.tar.gz -o musl-1.2.3.tar.gz \
    && curl https://musl.libc.org/releases/musl-1.2.3.tar.gz.asc -o musl-1.2.3.tar.gz.asc \
    && gpg --recv-key 836489290BB6B70F99FFDA0556BCDB593020450F \
    && gpg --verify musl-1.2.3.tar.gz.asc musl-1.2.3.tar.gz \
    && tar xf musl-1.2.3.tar.gz \
    && cd musl-1.2.3 \
    && sed -i 's/char buf\[2048\]/char buf[8192]/' src/dirent/__dirent.h \
    && ./configure \
    && make install

FROM alpine:3.16
RUN apk add cifs-utils mc
COPY --from=musl /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
$ docker compose up
$ docker compose exec alpine mount.cifs -o user=root,pass=badpass //samba/a /mnt
$ docker compose exec samba sh ./a.sh
$ docker compose exec alpine rm -r mnt/b

Actually there's a number of ways to increase the buffer. The one above won't work for alpine:3.15. At least you won't be able to use programs like gcc that use (directly or not) qsort_r(). To fix that you'll need the patch that was added to alpine:3.15:

FROM alpine:3.15 as musl
RUN set -x && apk add build-base curl gpg gnupg-dirmngr gpg-agent \
    && curl https://musl.libc.org/releases/musl-1.2.2.tar.gz -o musl-1.2.2.tar.gz \
    && curl https://musl.libc.org/releases/musl-1.2.2.tar.gz.asc -o musl-1.2.2.tar.gz.asc \
    && gpg --recv-key 836489290BB6B70F99FFDA0556BCDB593020450F \
    && gpg --verify musl-1.2.2.tar.gz.asc musl-1.2.2.tar.gz \
    && tar xf musl-1.2.2.tar.gz \
    && cd musl-1.2.2 \
    && curl https://gitlab.alpinelinux.org/alpine/aports/-/raw/3.15-stable/main/musl/qsort_r.patch -o qsort_r.patch \
    && patch -p1 < qsort_r.patch \
    && sed -i 's/char buf\[2048\]/char buf[8192]/' src/dirent/__dirent.h \
    && ./configure \
    && make install

FROM alpine:3.15
RUN apk add cifs-utils mc
COPY --from=musl /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1

Which brings to mind the option to use abuild:

FROM alpine:3.16 as musl
RUN set -x && . /etc/os-release \
    && apk add alpine-sdk sudo \
    && abuild-keygen -ain \
    && git clone --branch v"$VERSION_ID" --single-branch --depth 1 https://gitlab.alpinelinux.org/alpine/aports \
    && cd aports/main/musl \
    && sed -i '/build()/a \'$'\n'' \
        sed -i "s/char buf\\[2048\\]/char buf[8192]/" src/dirent/__dirent.h \
    ' APKBUILD \
    && abuild -rF \
    && apk add /root/packages/main/x86_64/musl-1.2.3-r0.apk

FROM alpine:3.16
RUN apk add cifs-utils mc
COPY --from=musl /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1

Alternatively, you can replace:

    && sed -i '/build()/a \'$'\n'' \
        sed -i "s/char buf\\[2048\\]/char buf[8192]/" src/dirent/__dirent.h \
    ' APKBUILD \

with:

    && echo '/build()/a \' > patch.sed \
    && echo 'sed -i "s/char buf\\[2048\\]/char buf[8192]/" src/dirent/__dirent.h' >> patch.sed \
    && sed -i -f patch.sed APKBUILD \

And if you don't want to build under root:

FROM alpine:3.16 as musl
RUN set -x && apk add alpine-sdk sudo shadow \
    && useradd -mG abuild build \
    && echo 'build ALL = NOPASSWD: /bin/mkdir -p /etc/apk/keys' > /etc/sudoers.d/build \
    && echo 'build ALL = NOPASSWD: /bin/cp /home/build/.abuild/build-*.rsa.pub /etc/apk/keys/' >> /etc/sudoers.d/build \
    && su - build -c ' \
        set -eu \
        ; . /etc/os-release \
        ; abuild-keygen -ain \
        ; git clone --branch v"$VERSION_ID" --single-branch --depth 1 https://gitlab.alpinelinux.org/alpine/aports \
        ; cd aports/main/musl \
        ; sed -i "/build()/a \\'$'\n'' \
            sed -i \"s/char buf\\\\[2048\\\\]/char buf[8192]/\" src/dirent/__dirent.h \
        " APKBUILD \
        ; abuild -r \
    ' \
    && apk add /home/build/packages/main/x86_64/musl-1.2.3-r0.apk

FROM alpine:3.16
RUN apk add cifs-utils mc
COPY --from=musl /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1

The patch.sed variant looks this way is this case:

        ; echo "/build()/a \\" > patch.sed \
        ; echo "sed -i \"s/char buf\\\\[2048\\\\]/char buf[8192]/\" src/dirent/__dirent.h" >> patch.sed \
        ; sed -i -f patch.sed APKBUILD \

musl readdir() Cannot Be Used Reliably on NFS/SMB Shares if Contents of Directory are Changing (e.g. during "rm -rf")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment