diff --git a/internal/fileutils/fileutils.go b/internal/fileutils/fileutils.go index d7f5f8815c..0a54facaba 100644 --- a/internal/fileutils/fileutils.go +++ b/internal/fileutils/fileutils.go @@ -7,6 +7,8 @@ import ( "io" "os" "path/filepath" + + "golang.org/x/sys/unix" ) // FileExists checks if a file exists at the given path. @@ -103,3 +105,28 @@ func Lrename(oldPath, newPath string) error { return os.Rename(oldPath, newPath) } + +// LockDir creates a lock file in the specified directory and acquires an exclusive lock on it. +// It blocks until the lock is available and returns an unlock function to release the lock. +func LockDir(dir string) (func() error, error) { + lockPath := filepath.Join(dir, ".lock") + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + _ = f.Close() + return nil, err + } + + unlock := func() error { + if err := unix.Flock(int(f.Fd()), unix.LOCK_UN); err != nil { + _ = f.Close() + return err + } + return f.Close() + } + + return unlock, nil +} diff --git a/internal/fileutils/fileutils_test.go b/internal/fileutils/fileutils_test.go index ddfebe7a3c..9195254a54 100644 --- a/internal/fileutils/fileutils_test.go +++ b/internal/fileutils/fileutils_test.go @@ -5,10 +5,12 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/ubuntu/authd/internal/fileutils" + "github.com/ubuntu/authd/internal/testutils" ) // errAny represents any error type, for testing purposes. @@ -352,3 +354,45 @@ func TestLrename(t *testing.T) { }) } } + +func TestLockDir(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // First lock should succeed + t.Log("Acquiring first lock") + unlock, err := fileutils.LockDir(tempDir) + require.NoError(t, err, "LockDir should not return an error") + + // Second lock should block, so we run it in a goroutine and check it doesn't return immediately + unlockCh := make(chan func() error, 1) + go func() { + t.Log("Acquiring second lock (should block)") + unlock2, err := fileutils.LockDir(tempDir) + t.Logf("Second LockDir returned with error: %v", err) + unlockCh <- unlock2 + }() + select { + case <-unlockCh: + require.Fail(t, "LockDir should block when trying to lock an already locked directory") + case <-time.After(testutils.MultipliedSleepDuration(100 * time.Millisecond)): + // Expected behavior, LockDir is blocking + } + + // Unlock the first lock + t.Log("Releasing first lock") + err = unlock() + require.NoError(t, err, "Unlock should not return an error") + + // Now we should be able to acquire the lock again + select { + case unlock = <-unlockCh: + // Expected behavior, LockDir returned after the first lock was released + t.Log("Releasing lock") + err = unlock() + require.NoError(t, err, "Unlock should not return an error") + case <-time.After(testutils.MultipliedSleepDuration(5 * time.Second)): + require.Fail(t, "LockDir should have returned after the first lock was released") + } +} diff --git a/internal/testutils/args.go b/internal/testutils/args.go index 949b314353..659e8a9dd8 100644 --- a/internal/testutils/args.go +++ b/internal/testutils/args.go @@ -16,8 +16,24 @@ var ( isRaceOnce sync.Once sleepMultiplier float64 sleepMultiplierOnce sync.Once + testVerbosity int + testVerbosityOnce sync.Once ) +// TestVerbosity returns the verbosity level that should be used in tests. +func TestVerbosity() int { + testVerbosityOnce.Do(func() { + if v := os.Getenv("AUTHD_TEST_VERBOSITY"); v != "" { + var err error + testVerbosity, err = strconv.Atoi(v) + if err != nil { + panic(err) + } + } + }) + return testVerbosity +} + func haveBuildFlag(flag string) bool { b, ok := debug.ReadBuildInfo() if !ok { diff --git a/internal/testutils/rust.go b/internal/testutils/rust.go index 7480142f00..9845034e1a 100644 --- a/internal/testutils/rust.go +++ b/internal/testutils/rust.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/ubuntu/authd/internal/fileutils" ) func getCargoPath() (path string, isNightly bool, err error) { @@ -74,14 +75,15 @@ func BuildRustNSSLib(t *testing.T, disableCoverage bool, features ...string) (li cargo, isNightly, err := getCargoPath() require.NoError(t, err, "Setup: looking for cargo") - // Note that for developing purposes and avoiding keeping building the rust program dependencies, - // TEST_RUST_TARGET environment variable can be set to an absolute path to keep iterative - // build artifacts. + // Store the build artifacts in a common temp directory, so that they can be reused between tests. target := os.Getenv("TEST_RUST_TARGET") if target == "" { - target = t.TempDir() + target = filepath.Join(os.TempDir(), "authd-tests-rust-build-artifacts") } + err = os.MkdirAll(target, 0700) + require.NoError(t, err, "Setup: could not create Rust target dir") + rustDir := filepath.Join(projectRoot, "nss") if !disableCoverage { rustCovEnv = trackRustCoverage(t, target, rustDir) @@ -89,20 +91,30 @@ func BuildRustNSSLib(t *testing.T, disableCoverage bool, features ...string) (li features = append([]string{"integration_tests", "custom_socket"}, features...) + unlock, err := fileutils.LockDir(target) + require.NoError(t, err, "Setup: could not lock Rust target dir") + defer func() { + require.NoError(t, unlock(), "Setup: could not unlock Rust target dir") + }() + // Builds the nss library. // #nosec:G204 - we control the command arguments in tests - cmd := exec.Command(cargo, "build", "--verbose", - "--features", strings.Join(features, ","), "--target-dir", target) + cmd := exec.Command(cargo, "build", "--features", strings.Join(features, ","), "--target-dir", target) + if TestVerbosity() > 0 { + cmd.Args = append(cmd.Args, "--verbose") + } cmd.Env = append(os.Environ(), rustCovEnv...) cmd.Dir = projectRoot + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr if isNightly && IsAsan() { cmd.Env = append(cmd.Env, "RUSTFLAGS=-Zsanitizer=address") } t.Log("Building NSS library...", cmd.Args) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "Setup: could not build Rust NSS library: %s", out) + err = cmd.Run() + require.NoError(t, err, "Setup: could not build Rust NSS library") // When building the crate with dh-cargo, this env is set to indicate which architecture the code // is being compiled to. When it's set, the compiled is stored under target/$(DEB_HOST_RUST_TYPE)/debug, @@ -111,9 +123,10 @@ func BuildRustNSSLib(t *testing.T, disableCoverage bool, features ...string) (li // If the env is not set, the target stays the same. target = filepath.Join(target, os.Getenv("DEB_HOST_RUST_TYPE")) - // Creates a symlink for the compiled library with the expected versioned name. - libPath = filepath.Join(target, "libnss_authd.so.2") - if err = os.Symlink(filepath.Join(target, "debug", "libnss_authd.so"), libPath); err != nil { + // Copy the library with the expected versioned name to a temporary directory, so that we can safely use + // it from there after unlocking the target directory, which allows other tests to rebuild the library. + libPath = filepath.Join(t.TempDir(), "libnss_authd.so.2") + if err = fileutils.CopyFile(filepath.Join(target, "debug", "libnss_authd.so"), libPath); err != nil { require.ErrorIs(t, err, os.ErrExist, "Setup: failed to create versioned link to the library") } diff --git a/nss/build.rs b/nss/build.rs index 927ae589ba..2b9e79539d 100644 --- a/nss/build.rs +++ b/nss/build.rs @@ -2,7 +2,10 @@ fn main() -> Result<(), Box> { tonic_build::configure() .build_server(false) .protoc_arg("--experimental_allow_proto3_optional") - .compile_protos(&["../internal/proto/authd/authd.proto"], &["../"])?; + .compile_protos( + &["../internal/proto/authd/authd.proto"], + &["../internal/proto"], + )?; #[cfg(feature = "integration_tests")] cc::Build::new()