test(source/file): fix coverage gap for symlink swaps with skipUnchanged

This commit is contained in:
Thibault VINCENT
2026-04-30 20:36:02 +02:00
parent efe4de88a9
commit d210862ed3
+117
View File
@@ -186,3 +186,120 @@ func TestNameAndDefaults(t *testing.T) {
t.Fail()
}
}
// swapSymlinkAtomic mirrors what certbot/kubelet do on cert renewal:
// create a fresh symlink under a temp name pointing at `target`, then
// rename it over `dst`. The visible inode at `dst` is brand new and
// carries a fresh mtime stamped at creation time.
func swapSymlinkAtomic(t *testing.T, dst, target string) {
t.Helper()
tmp := dst + ".tmp"
if err := os.Symlink(target, tmp); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, dst); err != nil {
t.Fatal(err)
}
}
// fsHasSubSecondMtime probes whether the filesystem backing `dir` stores
// sub-second mtime values. Linux ext4/tmpfs/btrfs/zfs/xfs do; HFS+ on
// older macOS and some FAT-family filesystems don't (1s granularity).
// When sub-second resolution is missing, two same-second operations
// produce identical mtimes — meaningful only for the test below, which
// needs a guaranteed mtime delta between two successive symlink swaps.
func fsHasSubSecondMtime(t *testing.T, dir string) bool {
t.Helper()
probe := filepath.Join(dir, ".mtime-probe")
if err := os.WriteFile(probe, nil, 0o600); err != nil {
t.Fatal(err)
}
defer os.Remove(probe)
want := time.Unix(0, 123456789) // arbitrary non-zero sub-second value
if err := os.Chtimes(probe, want, want); err != nil {
t.Fatal(err)
}
info, err := os.Stat(probe)
if err != nil {
t.Fatal(err)
}
return info.ModTime().Nanosecond() == want.Nanosecond()
}
// TestSkipUnchangedSymlinkTargetSwap covers the certbot / kubelet renewal
// pattern: a watched path is a symlink whose target is atomically swapped
// onto a new file. The (mtime, size) cache key must invalidate via either
// branch independently. We exercise both:
//
// 1. size branch — swap to a target with a different path-string length
// so the symlink's stored size differs. Mtime may or may not collide
// under coarse-resolution filesystems; size alone must invalidate.
// 2. mtime branch — swap back to a target with the SAME path string, so
// size is identical. Only mtime can differ. Probe the filesystem
// mtime resolution; sleep past 1 s when sub-second support is absent
// so two consecutive swaps are guaranteed to produce distinct mtimes.
func TestSkipUnchangedSymlinkTargetSwap(t *testing.T) {
dir := t.TempDir()
archive := filepath.Join(dir, "archive")
if err := os.Mkdir(archive, 0o755); err != nil {
t.Fatal(err)
}
// Three real PEMs. `long` and `short` have different path-string
// lengths so the symlink's size differs across branch 1; `replaced`
// has the same path as `short` but holds a fresh certificate so we
// can assert the bundle was actually re-parsed in branch 2.
long := filepath.Join(archive, "fullchain-renewed-1.pem")
short := filepath.Join(archive, "f.pem")
if len(long) == len(short) {
t.Fatalf("test bug: long and short paths must differ in length")
}
writePEM(t, long, "v1")
writePEM(t, short, "v2")
live := filepath.Join(dir, "fullchain.pem")
if err := os.Symlink(long, live); err != nil {
t.Fatal(err)
}
pat, _ := fileglob.Compile(live)
src := New(Options{
Name: "x",
Patterns: []fileglob.Pattern{pat},
Formats: []cert.FormatParser{pem.New()},
FollowSymlinks: true,
SkipUnchanged: true,
Jitter: 0,
}, nopLogger())
// Initial sync: one bundle for `live` parsed from `long`.
sink1 := &fakeSink{}
src.runOnce(context.Background(), sink1, true)
if len(sink1.upsert) != 1 {
t.Fatalf("first run: want 1 upsert, got %d", len(sink1.upsert))
}
// === Branch 1: size differs ===
// Swap onto `short` (different path length → different symlink size).
swapSymlinkAtomic(t, live, short)
sink2 := &fakeSink{}
src.runOnce(context.Background(), sink2, false)
if len(sink2.upsert) != 1 {
t.Fatalf("size branch: swap to shorter target should re-parse, got %d upserts", len(sink2.upsert))
}
// === Branch 2: only mtime differs ===
// Re-swap onto `short` again. Symlink string length is identical, so
// size is unchanged; only mtime can differ. Force a >1 s gap when the
// filesystem rounds mtime to second granularity so the new symlink's
// mtime is guaranteed to differ from the one cached above.
if !fsHasSubSecondMtime(t, dir) {
time.Sleep(1100 * time.Millisecond)
}
swapSymlinkAtomic(t, live, short)
sink3 := &fakeSink{}
src.runOnce(context.Background(), sink3, false)
if len(sink3.upsert) != 1 {
t.Fatalf("mtime branch: same-target swap should re-parse, got %d upserts", len(sink3.upsert))
}
}