Skip to content

Commit a38a9b7

Browse files
committed
fix(quadlet): fix race condition, duplicates, and permissions
Signed-off-by: Amol Yadav <amyssnipet@yahoo.com>
1 parent 559dce7 commit a38a9b7

2 files changed

Lines changed: 110 additions & 51 deletions

File tree

‎pkg/domain/infra/abi/quadlet.go‎

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
172172
baseName := strings.TrimSuffix(filepath.Base(toInstall), filepath.Ext(toInstall))
173173
assetFile = "." + baseName + ".app"
174174
} else {
175-
assetFile = "." + filepath.Base(toInstall) + ".asset"
175+
if systemdquadlet.IsExtSupported(toInstall) {
176+
assetFile = "." + filepath.Base(toInstall) + ".app"
177+
} else {
178+
assetFile = "." + filepath.Base(toInstall) + ".asset"
179+
}
176180
}
177181
validateQuadletFile = true
178182
}
@@ -335,63 +339,102 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins
335339
return "", fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(finalPath))
336340
}
337341

338-
osFlags := os.O_CREATE | os.O_WRONLY
342+
var destFile *os.File
343+
var tempPath string
339344

340345
if !replace {
341-
osFlags |= os.O_EXCL
346+
var err error
347+
// O_EXCL ensures we fail if the file already exists (avoids TOCTOU race)
348+
destFile, err = os.OpenFile(finalPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
349+
if err != nil {
350+
if errors.Is(err, fs.ErrExist) {
351+
return "", fmt.Errorf("a Quadlet with name %s already exists, refusing to overwrite", filepath.Base(finalPath))
352+
}
353+
return "", fmt.Errorf("unable to open file %s: %w", finalPath, err)
354+
}
355+
} else {
356+
var err error
357+
destFile, err = os.CreateTemp(filepath.Dir(finalPath), ".quadlet-install-*")
358+
if err != nil {
359+
return "", fmt.Errorf("unable to create temp file: %w", err)
360+
}
361+
tempPath = destFile.Name()
342362
}
343363

344-
file, err := os.OpenFile(finalPath, osFlags, 0o644)
345-
if err != nil {
346-
if errors.Is(err, fs.ErrExist) && !replace {
347-
return "", fmt.Errorf("a Quadlet with name %s already exists, refusing to overwrite", filepath.Base(finalPath))
364+
defer func() {
365+
if destFile != nil {
366+
destFile.Close()
348367
}
349-
return "", fmt.Errorf("unable to open file %s: %w", filepath.Base(finalPath), err)
350-
}
351-
defer file.Close()
368+
if tempPath != "" {
369+
os.Remove(tempPath)
370+
}
371+
}()
352372

353-
// Move the file in
354373
srcFile, err := os.Open(path)
355374
if err != nil {
356375
return "", fmt.Errorf("unable to open file: %w", err)
357376
}
358377
defer srcFile.Close()
359378

360-
err = fileutils.ReflinkOrCopy(srcFile, file)
379+
err = fileutils.ReflinkOrCopy(srcFile, destFile)
361380
if err != nil {
362381
return "", fmt.Errorf("unable to copy file from %s to %s: %w", path, finalPath, err)
363382
}
364383

365-
// When we install files using this function, caller of this function can turn off `validateQuadletFile`
366-
// when they are installing `non-quadlet` files.
384+
// Close before rename to flush writes; nil out to prevent double-close in defer
385+
if err := destFile.Close(); err != nil {
386+
return "", fmt.Errorf("unable to close file: %w", err)
387+
}
388+
destFile = nil
389+
390+
if tempPath != "" {
391+
if err := os.Chmod(tempPath, 0o644); err != nil {
392+
return "", fmt.Errorf("unable to set permissions on temp file: %w", err)
393+
}
394+
395+
if err := os.Rename(tempPath, finalPath); err != nil {
396+
return "", fmt.Errorf("unable to rename temp file to %s: %w", finalPath, err)
397+
}
398+
tempPath = ""
399+
}
400+
367401
if !isQuadletFile {
368-
err := appendStringToFile(filepath.Join(installDir, assetFile), filepath.Base(filepath.Clean(path)))
402+
err := appendLineToFile(filepath.Join(installDir, assetFile), filepath.Base(filepath.Clean(path)))
369403
if err != nil {
370404
return "", fmt.Errorf("error while writing non-quadlet filename: %w", err)
371405
}
372406
} else if strings.HasSuffix(assetFile, ".app") {
373-
// For quadlet files that are part of an application (indicated by .app extension),
374-
// also write the quadlet filename to the .app file for proper application tracking
375407
quadletName := filepath.Base(finalPath)
376-
err := appendStringToFile(filepath.Join(installDir, assetFile), quadletName)
408+
err := appendLineToFile(filepath.Join(installDir, assetFile), quadletName)
377409
if err != nil {
378410
return "", fmt.Errorf("error while writing quadlet filename to app file: %w", err)
379411
}
380412
}
381413
return finalPath, nil
382414
}
383415

384-
// appendStringToFile appends the given text to the specified file.
385-
// If the file does not exist, it will be created with 0644 permissions.
386-
func appendStringToFile(filePath, text string) error {
387-
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
416+
// appendLineToFile appends the given text as a line to the specified file,
417+
// ensuring it does not already exist (idempotency).
418+
func appendLineToFile(path, text string) error {
419+
content, err := os.ReadFile(path)
420+
if err == nil {
421+
for _, line := range strings.Split(string(content), "\n") {
422+
if line == text {
423+
return nil // Already exists, do nothing
424+
}
425+
}
426+
}
427+
428+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
388429
if err != nil {
389430
return err
390431
}
391432
defer f.Close()
392433

393-
_, err = f.WriteString(text + "\n")
394-
return err
434+
if _, err := f.WriteString(text + "\n"); err != nil {
435+
return err
436+
}
437+
return nil
395438
}
396439

397440
// quadletSection represents a single quadlet extracted from a multi-quadlet file

‎test/system/253-podman-quadlet.bats‎

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -469,43 +469,59 @@ EOF
469469
assert $status -eq 0 "quadlet rm --ignore should succeed even for non-existent quadlets"
470470
}
471471

472-
@test "quadlet install --replace" {
473-
local install_dir=$(get_quadlet_install_dir)
474-
# Create a test quadlet file
475-
local quadlet_file=$PODMAN_TMPDIR/alpine-quadlet.container
476-
local initial_exec='Exec=sh -c "echo STARTED CONTAINER; trap '\''exit'\'' SIGTERM; while :; do sleep 0.1; done"'
477-
cat > $quadlet_file <<EOF
472+
@test "podman quadlet install --replace" {
473+
# 1. Create a valid "Long" quadlet file with many environment variables
474+
cat > "$PODMAN_TMPDIR/long.container" <<EOF
478475
[Container]
479476
Image=$IMAGE
480-
$initial_exec
477+
Exec=sh -c "echo STARTED; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
481478
EOF
482-
# Test quadlet install
483-
run_podman quadlet install $quadlet_file
484-
# Verify install output contains the quadlet name on a single line
485-
assert "$output" =~ "alpine-quadlet.container" "install output should contain quadlet name"
479+
for i in {1..10}; do echo "Environment=VAR$i=VAL$i" >> "$PODMAN_TMPDIR/long.container"; done
480+
481+
# 2. Install the LONG file first
482+
run_podman quadlet install "$PODMAN_TMPDIR/long.container"
483+
is "$output" ".*long.container"
486484

487-
# Without replace should fail
488-
run_podman 125 quadlet install $quadlet_file
485+
# 3. Without replace should fail
486+
run_podman 125 quadlet install "$PODMAN_TMPDIR/long.container"
489487
assert "$output" =~ "refusing to overwrite" "reinstall without --replace must fail with the overwrite error message"
490488

491-
cat > $quadlet_file <<EOF
489+
# 4. Overwrite the source file with valid "Short" content
490+
cat > "$PODMAN_TMPDIR/long.container" <<EOF
492491
[Container]
493-
Image=$IMAGE
494-
Exec=sh -c "echo STARTED CONTAINER UPDATED; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
492+
Image=alpine
495493
EOF
496-
# With replace should pass and update quadlet
497-
run_podman quadlet install --replace $quadlet_file
498494

499-
# Verify install output contains the quadlet name on a single line
500-
assert "$output" =~ "alpine-quadlet.container" "install output should contain quadlet name"
495+
# 5. Install the SAME file again with --replace
496+
run_podman quadlet install --replace "$PODMAN_TMPDIR/long.container"
501497

502-
run_podman quadlet print alpine-quadlet.container
498+
# --- VERIFICATION 1: CHECK FOR TRUNCATION ---
499+
local install_dir=$(get_quadlet_install_dir)
500+
run cat "$install_dir/long.container"
501+
assert "$output" == "$(<$PODMAN_TMPDIR/long.container)" "File was correctly truncated/replaced atomically"
503502

504-
assert "$output" !~ "$initial_exec" "Printed content must not show the initial version"
505-
assert "$output" == "$(<$quadlet_file)" "Printed content must match the updated file content"
503+
# --- VERIFICATION 2: CHECK FOR DUPLICATES IN .APP FILE ---
506504

507-
# Clean up
508-
run_podman quadlet rm alpine-quadlet.container
509-
}
505+
local app_file="$install_dir/.long.container.app"
510506

511-
# vim: filetype=sh
507+
# Check if the file exists
508+
if [ ! -f "$app_file" ]; then
509+
# If .app is missing, check if .asset was created instead (debugging IsExtSupported)
510+
if [ -f "$install_dir/.long.container.asset" ]; then
511+
die "Failed: Created .asset file instead of .app file. IsExtSupported check failed?"
512+
fi
513+
die "Failed: .app file not found at $app_file"
514+
fi
515+
516+
# Check content of the .app file
517+
run cat "$app_file"
518+
# It should contain exactly one line: "long.container"
519+
assert "$output" == "long.container" ".app file should contain the quadlet name"
520+
521+
# Ensure no duplicates (line count should be 1)
522+
run wc -l < "$app_file"
523+
assert "$output" -eq 1 "Should only be listed once in tracking files"
524+
525+
# Cleanup: Remove the installed quadlet
526+
run_podman quadlet rm long.container
527+
}

0 commit comments

Comments
 (0)