From c025476c18e35cc0b174419543cd8ac57d5ffd73 Mon Sep 17 00:00:00 2001 From: Chandarith Chea <45872539+chandarith-chea@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:08:55 +0700 Subject: [PATCH] A hard link can be made inside a bind mount or named volume docker run -v vol:/v alpine sh -c 'echo x > /v/a; ln /v/a /v/b' printed "ln: /v/b: No such file or directory" (Dockiva 0.6.10); on Linux it works, and so did the same link in the container's own layer. linkat refused a source the walk found in neither image layer (layer 2) with ENOENT before asking the host. For a leaf inside a bind mount the walk leaves the layer at 2 and the canonical path empty: the layers do not apply there. So every source in a mount counted as missing whenever the container had image layers. The check now applies only to a leaf in the layers; in a mount the host's linkat reports a missing source. Links across a mount's boundary are EXDEV as before (decided earlier, by the mounts of the two paths). tests/native/mountlink_guest.c (scripts/verify_overlay.py, every built runtime, with and without the memory layer): a link inside a mount by absolute path, by a descriptor of the mount's directory and through a symlink with AT_SYMLINK_FOLLOW shares the source's inode; a missing source is ENOENT, a taken name EEXIST; a link to another mount, or to or from the layers, is EXDEV; links of image files still copy up. The verifier then checks the host's directories: one inode under three names, nothing in the other mount. --- native/fs.h | 2 +- scripts/verify_overlay.py | 37 ++++++++++++++++++- tests/native/mountlink_guest.c | 65 ++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/native/mountlink_guest.c diff --git a/native/fs.h b/native/fs.h index bbc3cd7..1fbe778 100644 --- a/native/fs.h +++ b/native/fs.h @@ -1833,7 +1833,7 @@ static uint64_t fs_call(uint64_t call) { if(!fs_path_writable())error=linux_error(30); else if(where!=fs_where || where<0)error=linux_error(18); /* EXDEV: never across a mount boundary */ else if(!strcmp(from,".") || !strcmp(leaf,"."))error=linux_error(!strcmp(leaf,".")?17:1); - else if(old.layer==2)error=linux_error(2); + else if(old.canon[0] && old.layer==2)error=linux_error(2); /* in the layers; in a bind mount (canon "") the host's linkat reports it */ else if(ov_prepare(&w,&parent,leaf,OV_CREATE) || ov_prepare(&old,&source,from,OV_MODIFY))error=fs_error(); /* image layers: a lower file is copied up */ else if(linkat(source,from,parent,leaf,0)<0)error=fs_error(); else ov_created(&w,parent,leaf,0); diff --git a/scripts/verify_overlay.py b/scripts/verify_overlay.py index 8fb191f..3a1b6a2 100644 --- a/scripts/verify_overlay.py +++ b/scripts/verify_overlay.py @@ -4,7 +4,8 @@ over one shared lower tree, the second run proving the first left the shared files alone); new files kept in memory (--upper-memory, native/upm.h) renamed while open over lower and upper files (tests/native/upm_guest.c), and what that run wrote -back: each file whole, and no whiteout beside it; then, with alpine as the lower +back: each file whole, and no whiteout beside it; hard links inside a bind mount +(tests/native/mountlink_guest.c), never across its boundary; then, with alpine as the lower layer, busybox copying, archiving, renaming and removing image files, and a container trying to change the image's files every way it can (writes, chmod, rename, links, truncate, MAP_SHARED writes, sockets, @@ -75,6 +76,39 @@ def runtimes(): print(f'{binary}: skipped (not built)') +def mount_link_runs(t): + """mountlink_guest.c on every built runtime, with and without the memory layer, bind + mounts at /vol and /vol2; then what it left: one file under its three names in the + first mount, nothing in the second, no whiteout in either.""" + failed = 0 + lower = t/'link-lower' + (lower/'low').mkdir(parents=True) + (lower/'low/f').write_text('f') + build(ROOT/'tests/native/mountlink_guest.c', t/'mountlink') + before = snapshot(lower) + for binary, runtime, extra in runtimes(): + for memory in (False, True): + tag = f'{binary}-{int(memory)}' + upper, state, vol, vol2 = t/f'link-up-{tag}', t/f'link-state-{tag}', t/f'link-vol-{tag}', t/f'link-vol2-{tag}' + for d in (upper, state, vol, vol2): + d.mkdir() + more = ['--upper-memory', str(state/'upper-sync'), '--layers-gen', str(state/'gen')] if memory else [] + r = subprocess.run([str(runtime), 'run', str(t/'mountlink'), '--root', str(upper), '--lower', str(lower), '--writable', '--bind', str(vol), '/vol', '--bind', str(vol2), '/vol2', + '--storage-limit-mib', '0', '--max-steps', '2000000000', '--process-tree', '--scheduled-network', '--dynamic', *more, *extra], + capture_output=True, timeout=300) + ok = r.returncode == 0 and r.stdout == b'mountlink PASS\n' and b'ERROR: AddressSanitizer' not in r.stderr + names = sorted(str(p.relative_to(vol)) for p in vol.rglob('*')) + ok = ok and names == ['a', 'b', 'd', 'd/c', 's'] and len({(vol/n).stat().st_ino for n in ('a', 'b', 'd/c')}) == 1 and (vol/'a').stat().st_nlink == 3 + ok = ok and not os.listdir(vol2) and not os.path.lexists(upper/'x') and (upper/'low/g').is_file() + failed += not ok + print(f'{binary} mountlink_guest{" with the memory layer" if memory else ""}:', 'PASS' if ok else + f'FAIL exit={r.returncode} {r.stdout!r} vol={names} vol2={os.listdir(vol2)} {r.stderr.decode()[-1500:]}') + same = snapshot(lower) == before + failed += not same + print('mountlink lower layer unchanged:', 'PASS' if same else 'FAIL') + return failed + + # upm_guest.c's lower tree, and what its run leaves in the upper layer: files written # back, and the whiteouts of the lower files it removed (and only those). UPM_LOWER = {'u/lower': 'old lower', 'u/lower2': 'old lower2', 'u/up': 'old up', 'u/app.log.1': 'old log', 'u/k': 'k', 'u/nr': 'nr', @@ -137,6 +171,7 @@ def main(): lower.mkdir() lower_tree(lower) build(ROOT/'tests/native/overlay_guest.c', t/'overlay') + failed += mount_link_runs(t) before = snapshot(lower) for binary, runtime, extra in runtimes(): for run in (1, 2): # the second run sees the lower layer as the first did, with the lookup cache diff --git a/tests/native/mountlink_guest.c b/tests/native/mountlink_guest.c new file mode 100644 index 0000000..043e210 --- /dev/null +++ b/tests/native/mountlink_guest.c @@ -0,0 +1,65 @@ +/* Hard links inside a bind mount or named volume beside image layers (native/fs.h: + linkat): both names are one file (one inode, two links), by absolute path, by a + descriptor of the mount's directory, and through a symlink with AT_SYMLINK_FOLLOW; a + missing source is ENOENT, a taken name EEXIST, and a link that would leave the mount + (to another mount, to or from the container's layers) is EXDEV. /vol and /vol2 are + bind mounts, /low/f a file of the image. Run by scripts/verify_overlay.py, with and + without the memory layer, which then checks what the run left on the host. */ +typedef unsigned long U; +static long call(U n,U a,U b,U c,U d,U e,U f) { + register U x8 __asm__("x8")=n,x0 __asm__("x0")=a,x1 __asm__("x1")=b; + register U x2 __asm__("x2")=c,x3 __asm__("x3")=d,x4 __asm__("x4")=e,x5 __asm__("x5")=f; + __asm__ volatile("svc #0":"+r"(x0):"r"(x8),"r"(x1),"r"(x2),"r"(x3),"r"(x4),"r"(x5):"memory","cc");return (long)x0; +} +#define C1(n,a) call(n,(U)(a),0,0,0,0,0) +#define C3(n,a,b,c) call(n,(U)(a),(U)(b),(U)(c),0,0,0) +#define C4(n,a,b,c,d) call(n,(U)(a),(U)(b),(U)(c),(U)(d),0,0) +#define C5(n,a,b,c,d,e) call(n,(U)(a),(U)(b),(U)(c),(U)(d),(U)(e),0) +#define AT (-100) +#define ENOENT (-2) +#define EEXIST (-17) +#define EXDEV (-18) +static void fail_at(int line) { + char text[40]="mountlink FAIL line ";unsigned n=20;char digits[8];unsigned k=0; + while(line && k<8) { digits[k++]=(char)('0'+line%10);line/=10; } + while(k)text[n++]=digits[--k]; + text[n++]='\n';C3(64,1,text,n);C1(94,1);for(;;){} +} +#define CHECK(x) do { if(!(x))fail_at(__LINE__); } while(0) +static unsigned long u64(const unsigned char *p) { unsigned long v=0;for(int i=7;i>=0;i--)v=v<<8|p[i];return v; } +static unsigned char st[128]; +static long stat_of(const char *path) { return C4(79,AT,path,st,256); } /* AT_SYMLINK_NOFOLLOW */ +static unsigned long ino(const char *path) { return stat_of(path)?0:u64(st+8); } +static long links(const char *path) { return stat_of(path)?-1:(long)(st[20]|st[21]<<8); } +static long put(const char *path) { + long fd=C4(56,AT,path,01101,0644);if(fd<0)return fd; /* O_WRONLY|O_CREAT|O_TRUNC */ + long n=C3(64,fd,"x",1);C1(57,fd);return n; +} +static int has(const char *path) { + char c[2];long fd=C4(56,AT,path,0,0);if(fd<0)return 0; + long n=C3(63,fd,c,2);C1(57,fd);return n==1 && c[0]=='x'; +} +static long link(const char *from,const char *to) { return C5(37,AT,from,AT,to,0); } + +void _start(void) { + CHECK(put("/vol/a")==1 && C3(34,AT,"/vol/d",0755)==0); + /* inside the mount: one file under both names */ + CHECK(link("/vol/a","/vol/b")==0); + CHECK(ino("/vol/a") && ino("/vol/a")==ino("/vol/b") && links("/vol/a")==2 && has("/vol/b")); + CHECK(link("/vol/b","/vol/d/c")==0 && ino("/vol/d/c")==ino("/vol/a") && links("/vol/a")==3); + long dir=C4(56,AT,"/vol",040000,0);CHECK(dir>=0); /* O_DIRECTORY */ + CHECK(C5(37,dir,"a",dir,"d/r",0)==0 && ino("/vol/d/r")==ino("/vol/a"));C1(57,dir); + CHECK(C3(36,"a",AT,"/vol/s")==0 && C5(37,AT,"/vol/s",AT,"/vol/t",0x400)==0 && ino("/vol/t")==ino("/vol/a")); /* AT_SYMLINK_FOLLOW */ + CHECK(C3(35,AT,"/vol/t",0)==0 && C3(35,AT,"/vol/d/r",0)==0 && links("/vol/a")==3 && has("/vol/a")); + CHECK(link("/vol/none","/vol/e")==ENOENT && stat_of("/vol/e")==ENOENT); + CHECK(link("/vol/a","/vol/b")==EEXIST && link("/vol/a","/vol/none/e")==ENOENT); + /* never across the mount's boundary */ + CHECK(link("/vol/a","/vol2/x")==EXDEV && stat_of("/vol2/x")==ENOENT); + CHECK(link("/vol/a","/x")==EXDEV && link("/vol/a","/low/x")==EXDEV && stat_of("/x")==ENOENT && stat_of("/low/x")==ENOENT); + CHECK(link("/low/f","/vol/x")==EXDEV && put("/up")==1 && link("/up","/vol/x")==EXDEV && stat_of("/vol/x")==ENOENT); + /* the layers as before: an image file is linked (copied up), a missing one is not */ + CHECK(link("/low/f","/low/g")==0 && ino("/low/g")==ino("/low/f") && links("/low/f")==2); + CHECK(link("/none","/y")==ENOENT && link("/low/none","/low/y")==ENOENT); + CHECK(links("/vol/a")==3); + C3(64,1,"mountlink PASS\n",15);C1(94,0);for(;;){} +}