// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package main

import (
	"crypto/sha256"
	"errors"
	"fmt"
	"io"
	"os"
)

// The functions in this file are based on go's own cache in
// cmd/go/internal/cache/cache.go, particularly putIndexEntry and copyFile.

// writeActionFile writes the indexEntry metadata for an ActionID to disk. It
// may be called for the same actionID concurrently from multiple processes,
// and the outputID for a specific actionID may change from time to time due
// to non-deterministic builds. It makes a best-effort to delete the file if
// anything goes wrong.
func writeActionFile(dest string, b []byte) (retErr error) {
	f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE, 0o666)
	if err != nil {
		return err
	}
	defer func() {
		cerr := f.Close()
		if retErr != nil || cerr != nil {
			retErr = errors.Join(retErr, cerr, os.Remove(dest))
		}
	}()

	_, err = f.Write(b)
	if err != nil {
		return err
	}

	// Truncate the file only *after* writing it.
	// (This should be a no-op, but truncate just in case of previous corruption.)
	//
	// This differs from os.WriteFile, which truncates to 0 *before* writing
	// via os.O_TRUNC. Truncating only after writing ensures that a second write
	// of the same content to the same file is idempotent, and does not - even
	// temporarily! - undo the effect of the first write.
	return f.Truncate(int64(len(b)))
}

// writeOutputFile writes content to be cached to disk. The outputID is the
// sha256 hash of the content, and each file should only be written ~once,
// assuming no sha256 hash collisions. It may be written multiple times if
// concurrent processes are both populating the same output. The file is opened
// with FILE_SHARE_READ|FILE_SHARE_WRITE, which means both processes can write
// the same contents concurrently without conflict.
//
// It makes a best effort to clean up if anything goes wrong, but the file may
// be left in an inconsistent state in the event of disk-related errors such as
// another process taking file locks, or power loss etc.
func writeOutputFile(dest string, r io.Reader, size int64, outputID string) (_ int64, retErr error) {
	info, err := os.Stat(dest)
	if err == nil && info.Size() == size {
		// Already exists, check the hash.
		if f, err := os.Open(dest); err == nil {
			h := sha256.New()
			io.Copy(h, f)
			f.Close()
			if fmt.Sprintf("%x", h.Sum(nil)) == outputID {
				// Still drain the reader to ensure associated resources are released.
				return io.Copy(io.Discard, r)
			}
		}
	}

	// Didn't successfully find the pre-existing file, write it.
	mode := os.O_WRONLY | os.O_CREATE
	if err == nil && info.Size() > size {
		mode |= os.O_TRUNC // Should never happen, but self-heal.
	}
	f, err := os.OpenFile(dest, mode, 0644)
	if err != nil {
		return 0, fmt.Errorf("failed to open output file %q: %w", dest, err)
	}
	defer func() {
		cerr := f.Close()
		if retErr != nil || cerr != nil {
			retErr = errors.Join(retErr, cerr, os.Remove(dest))
		}
	}()

	// Copy file to f, but also into h to double-check hash.
	h := sha256.New()
	w := io.MultiWriter(f, h)
	n, err := io.Copy(w, r)
	if err != nil {
		return 0, err
	}
	if fmt.Sprintf("%x", h.Sum(nil)) != outputID {
		return 0, errors.New("file content changed underfoot")
	}

	return n, nil
}
