| 1 | //go:build linux |
| 2 | |
| 3 | package instancefs |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "golang.org/x/sys/unix" |
| 14 | ) |
| 15 | |
| 16 | func TestOpenBeneathReadsAPlainFile(t *testing.T) { |
| 17 | dir := t.TempDir() |
| 18 | if err := os.WriteFile(filepath.Join(dir, "h.env"), []byte("A=1\n"), 0o600); err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | got, err := ReadBeneath(dir, "h.env") |
| 22 | if err != nil { |
| 23 | t.Fatalf("ReadBeneath: %v", err) |
| 24 | } |
| 25 | if string(got) != "A=1\n" { |
| 26 | t.Errorf("got %q", got) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func TestOpenBeneathRefusesASymlinkAtTheLeaf(t *testing.T) { |
| 31 | dir := t.TempDir() |
| 32 | secret := filepath.Join(t.TempDir(), "secret") |
| 33 | if err := os.WriteFile(secret, []byte("token"), 0o600); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | if err := os.Symlink(secret, filepath.Join(dir, "h.env")); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | _, err := ReadBeneath(dir, "h.env") |
| 40 | if !errors.Is(err, ErrSymlink) { |
| 41 | t.Fatalf("ReadBeneath through a symlink: err = %v, want ErrSymlink", err) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestOpenBeneathRefusesASymlinkedDirectory(t *testing.T) { |
| 46 | dir := t.TempDir() |
| 47 | other := t.TempDir() |
| 48 | if err := os.WriteFile(filepath.Join(other, "x"), []byte("x"), 0o600); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if err := os.Symlink(other, filepath.Join(dir, "sub")); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | _, err := ReadBeneath(dir, "sub/x") |
| 55 | if !errors.Is(err, ErrSymlink) { |
| 56 | t.Fatalf("err = %v, want ErrSymlink", err) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // TestOpenBeneathRefusesASymlinkedDir pins the review fix: openDir must |
| 61 | // refuse a symlink at dir itself, not only at an intermediate component |
| 62 | // under it. A same-uid process that swaps the instance directory for a |
| 63 | // symlink before the mount option lands must be caught the same way a |
| 64 | // symlink deeper in the path already is. |
| 65 | func TestOpenBeneathRefusesASymlinkedDir(t *testing.T) { |
| 66 | real := t.TempDir() |
| 67 | if err := os.WriteFile(filepath.Join(real, "x"), []byte("x"), 0o600); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | link := filepath.Join(t.TempDir(), "linked-dir") |
| 71 | if err := os.Symlink(real, link); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | _, err := ReadBeneath(link, "x") |
| 75 | if !errors.Is(err, ErrSymlink) { |
| 76 | t.Fatalf("ReadBeneath(%q, \"x\"): err = %v, want ErrSymlink", link, err) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func TestOpenBeneathRefusesDotDotAndAbsolute(t *testing.T) { |
| 81 | dir := t.TempDir() |
| 82 | for _, name := range []string{"../x", "a/../../x", "/etc/passwd"} { |
| 83 | _, err := ReadBeneath(dir, name) |
| 84 | if !errors.Is(err, ErrEscapes) { |
| 85 | t.Errorf("ReadBeneath(%q): err = %v, want ErrEscapes", name, err) |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestWriteAtomicReplacesAndNeverFollows(t *testing.T) { |
| 91 | dir := t.TempDir() |
| 92 | if err := WriteAtomic(dir, "h.config.env", []byte("one\n"), 0o600); err != nil { |
| 93 | t.Fatalf("WriteAtomic: %v", err) |
| 94 | } |
| 95 | if err := WriteAtomic(dir, "h.config.env", []byte("two\n"), 0o600); err != nil { |
| 96 | t.Fatalf("WriteAtomic (replace): %v", err) |
| 97 | } |
| 98 | got, err := os.ReadFile(filepath.Join(dir, "h.config.env")) |
| 99 | if err != nil || string(got) != "two\n" { |
| 100 | t.Fatalf("got %q, %v", got, err) |
| 101 | } |
| 102 | fi, err := os.Stat(filepath.Join(dir, "h.config.env")) |
| 103 | if err != nil || fi.Mode().Perm() != 0o600 { |
| 104 | t.Errorf("mode = %v, %v", fi.Mode(), err) |
| 105 | } |
| 106 | // A symlink planted at the destination is REPLACED as a directory |
| 107 | // entry; its target is untouched. |
| 108 | victim := filepath.Join(t.TempDir(), "victim") |
| 109 | if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if err := os.Remove(filepath.Join(dir, "h.config.env")); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | if err := os.Symlink(victim, filepath.Join(dir, "h.config.env")); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if err := WriteAtomic(dir, "h.config.env", []byte("three\n"), 0o600); err != nil { |
| 119 | t.Fatalf("WriteAtomic over a symlink: %v", err) |
| 120 | } |
| 121 | if b, _ := os.ReadFile(victim); string(b) != "keep" { |
| 122 | t.Errorf("symlink target was written through: %q", b) |
| 123 | } |
| 124 | if fi, err := os.Lstat(filepath.Join(dir, "h.config.env")); err != nil || fi.Mode()&os.ModeSymlink != 0 { |
| 125 | t.Errorf("destination is still a symlink: %v %v", fi, err) |
| 126 | } |
| 127 | // No temp file is left behind. |
| 128 | entries, _ := os.ReadDir(dir) |
| 129 | for _, e := range entries { |
| 130 | if strings.Contains(e.Name(), ".tmp-") { |
| 131 | t.Errorf("temp file left behind: %s", e.Name()) |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestMountNoSymfollowReadsMountinfo(t *testing.T) { |
| 137 | // / is a mount and does not carry nosymfollow on any ordinary box; a |
| 138 | // path that is not a mountpoint is reported as not a nosymfollow mount |
| 139 | // with a nil error (the caller's message names the path). |
| 140 | got, err := MountNoSymfollow("/") |
| 141 | if err != nil { |
| 142 | t.Fatalf("MountNoSymfollow(/): %v", err) |
| 143 | } |
| 144 | if got { |
| 145 | t.Error("/ reported as nosymfollow") |
| 146 | } |
| 147 | got, err = MountNoSymfollow(t.TempDir()) |
| 148 | if err != nil || got { |
| 149 | t.Errorf("temp dir: %v %v", got, err) |
| 150 | } |
| 151 | // The parser, against a fixture line, since a real nosymfollow mount |
| 152 | // needs root to create. |
| 153 | line := "36 35 98:0 /.instances /data/instances rw,nosuid,nodev,noexec,nosymfollow,relatime - ext4 /dev/root rw" |
| 154 | if !mountinfoLineHas(line, "/data/instances", "nosymfollow") { |
| 155 | t.Error("parser missed nosymfollow on a fixture line") |
| 156 | } |
| 157 | if mountinfoLineHas(line, "/data", "nosymfollow") { |
| 158 | t.Error("parser matched the wrong mountpoint") |
| 159 | } |
| 160 | |
| 161 | // Stacked mounts at the same mountpoint list oldest first; the LAST |
| 162 | // line is the one actually in effect, so the verdict must follow it |
| 163 | // even when an earlier line at the same mountpoint disagrees. |
| 164 | first := "36 35 98:0 /.instances /data/instances rw,nosuid,nodev,noexec,relatime - ext4 /dev/root rw" |
| 165 | last := "37 35 98:0 /.instances /data/instances rw,nosuid,nodev,noexec,nosymfollow,relatime - ext4 /dev/root rw" |
| 166 | stacked := strings.NewReader(first + "\n" + last + "\n") |
| 167 | got, err = mountOptionOf(stacked, "/data/instances", "nosymfollow") |
| 168 | if err != nil || !got { |
| 169 | t.Errorf("stacked mounts, last with nosymfollow: got %v, %v, want true, nil", got, err) |
| 170 | } |
| 171 | |
| 172 | stackedReversed := strings.NewReader(last + "\n" + first + "\n") |
| 173 | got, err = mountOptionOf(stackedReversed, "/data/instances", "nosymfollow") |
| 174 | if err != nil || got { |
| 175 | t.Errorf("stacked mounts, last WITHOUT nosymfollow: got %v, %v, want false, nil (the earlier nosymfollow line must not outvote the effective one)", got, err) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // TestMountOptionOfIsTheGeneralForm pins that MountNoSymfollow is now a |
| 180 | // thin wrapper over MountOptionOf, not a separate implementation: asking |
| 181 | // for an option other than nosymfollow (here "ro", the option the |
| 182 | // cgroup-ro-mount probe check asks about) reads the same field. |
| 183 | func TestMountOptionOfIsTheGeneralForm(t *testing.T) { |
| 184 | line := "36 35 98:0 / /sys/fs/cgroup ro,nosuid,nodev,noexec - cgroup2 cgroup2 rw" |
| 185 | got, err := mountOptionOf(strings.NewReader(line+"\n"), "/sys/fs/cgroup", "ro") |
| 186 | if err != nil || !got { |
| 187 | t.Errorf("MountOptionOf(/sys/fs/cgroup, ro): got %v, %v, want true, nil", got, err) |
| 188 | } |
| 189 | got, err = mountOptionOf(strings.NewReader(line+"\n"), "/sys/fs/cgroup", "nosymfollow") |
| 190 | if err != nil || got { |
| 191 | t.Errorf("MountOptionOf(/sys/fs/cgroup, nosymfollow) on a line without it: got %v, %v, want false, nil", got, err) |
| 192 | } |
| 193 | got, err = MountOptionOf("/", "ro") |
| 194 | if err != nil { |
| 195 | t.Fatalf("MountOptionOf(/, ro) against the live mountinfo: %v", err) |
| 196 | } |
| 197 | _ = got // / is not reliably ro or rw across test hosts; only the absence of an error matters here. |
| 198 | } |
| 199 | |
| 200 | // TestOpenBeneathRefusesAFIFOInsteadOfBlocking pins the review fix: a FIFO |
| 201 | // with no writer blocks open(2) indefinitely, and a tenant can plant one at |
| 202 | // any name the agent reads by contract. The read must fail fast with |
| 203 | // ErrNotRegular rather than hang the caller, so this runs it in a goroutine |
| 204 | // with its own timeout: a regression here must fail the test, not wedge it. |
| 205 | func TestOpenBeneathRefusesAFIFOInsteadOfBlocking(t *testing.T) { |
| 206 | dir := t.TempDir() |
| 207 | if err := unix.Mkfifo(filepath.Join(dir, "h.env"), 0o600); err != nil { |
| 208 | t.Fatalf("Mkfifo: %v", err) |
| 209 | } |
| 210 | done := make(chan error, 1) |
| 211 | go func() { |
| 212 | _, err := ReadBeneath(dir, "h.env") |
| 213 | done <- err |
| 214 | }() |
| 215 | select { |
| 216 | case err := <-done: |
| 217 | if !errors.Is(err, ErrNotRegular) { |
| 218 | t.Fatalf("ReadBeneath a FIFO: err = %v, want ErrNotRegular", err) |
| 219 | } |
| 220 | case <-time.After(2 * time.Second): |
| 221 | t.Fatal("ReadBeneath blocked on a FIFO with no writer instead of refusing it") |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // TestOpenBeneathRefusesAWriteOpenOfAFIFO pins the write-side half of the |
| 226 | // same fix: a write-open of a FIFO with no reader on the other end blocks |
| 227 | // open(2) indefinitely too, and the admin token file this backs an append |
| 228 | // onto is on the same tenant-writable ground as anything OpenBeneath reads. |
| 229 | // O_NONBLOCK turns that into an immediate ENXIO instead. |
| 230 | func TestOpenBeneathRefusesAWriteOpenOfAFIFO(t *testing.T) { |
| 231 | dir := t.TempDir() |
| 232 | if err := unix.Mkfifo(filepath.Join(dir, "token"), 0o600); err != nil { |
| 233 | t.Fatalf("Mkfifo: %v", err) |
| 234 | } |
| 235 | done := make(chan error, 1) |
| 236 | go func() { |
| 237 | _, err := OpenBeneath(dir, "token", os.O_WRONLY|os.O_APPEND, 0) |
| 238 | done <- err |
| 239 | }() |
| 240 | select { |
| 241 | case err := <-done: |
| 242 | if err == nil { |
| 243 | t.Fatal("write-open of a FIFO with no reader succeeded, want an error") |
| 244 | } |
| 245 | case <-time.After(2 * time.Second): |
| 246 | t.Fatal("write-open blocked on a FIFO with no reader instead of refusing it") |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | func TestReadBeneathLimitRefusesALargeFile(t *testing.T) { |
| 251 | dir := t.TempDir() |
| 252 | if err := os.WriteFile(filepath.Join(dir, "big"), make([]byte, 4096), 0o600); err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | if _, err := ReadBeneathLimit(dir, "big", 1024); !errors.Is(err, ErrTooLarge) { |
| 256 | t.Fatalf("err = %v, want ErrTooLarge", err) |
| 257 | } |
| 258 | got, err := ReadBeneathLimit(dir, "big", 4096) |
| 259 | if err != nil || len(got) != 4096 { |
| 260 | t.Fatalf("exact limit: %d bytes, %v", len(got), err) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // TestSelfCheckPassesWhereOpenat2Works pins the shape of the agent's own |
| 265 | // openat2 check: a plain directory passes, a symlinked one is refused as a |
| 266 | // symlink (the same rule as every other open here), and a missing one |
| 267 | // reports its own error rather than ErrOpenat2Unavailable, which is kept |
| 268 | // for the one answer that means the call itself was blocked. |
| 269 | func TestSelfCheckPassesWhereOpenat2Works(t *testing.T) { |
| 270 | dir := t.TempDir() |
| 271 | if err := SelfCheck(dir); err != nil { |
| 272 | t.Fatalf("SelfCheck(%s) = %v, want nil", dir, err) |
| 273 | } |
| 274 | link := filepath.Join(dir, "link") |
| 275 | if err := os.Symlink(dir, link); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | if err := SelfCheck(link); !errors.Is(err, ErrSymlink) { |
| 279 | t.Errorf("SelfCheck(symlink) = %v, want ErrSymlink", err) |
| 280 | } |
| 281 | err := SelfCheck(filepath.Join(dir, "missing")) |
| 282 | if err == nil || errors.Is(err, ErrOpenat2Unavailable) { |
| 283 | t.Errorf("SelfCheck(missing) = %v, want ENOENT and not ErrOpenat2Unavailable", err) |
| 284 | } |
| 285 | } |
| 286 | |