Files
Sylve/pkg/utils/fs.go
T

708 lines
14 KiB
Go

// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package utils
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/h2non/filetype"
"github.com/h2non/filetype/types"
)
func DeleteFile(path string) error {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("stat %q: %w", path, err)
}
if info.IsDir() {
return fmt.Errorf("%q is a directory, not a file", path)
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove %q: %w", path, err)
}
return nil
}
func DeleteFileIfExists(path string) error {
err := os.Remove(path)
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func CopyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer sourceFile.Close()
destinationFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer destinationFile.Close()
_, err = io.Copy(destinationFile, sourceFile)
if err != nil {
return fmt.Errorf("failed to copy file: %w", err)
}
err = destinationFile.Sync()
if err != nil {
return fmt.Errorf("failed to sync destination file: %w", err)
}
return nil
}
func FindFileInDirectoryByPrefix(dir, prefix string) (string, error) {
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if len(d.Name()) >= len(prefix) && d.Name()[:len(prefix)] == prefix {
return fmt.Errorf("FOUND:%s", path)
}
return nil
})
if err != nil && len(err.Error()) > 6 && err.Error()[:6] == "FOUND:" {
return err.Error()[6:], nil
}
if err != nil {
return "", fmt.Errorf("walk_error: %w", err)
}
return "", fmt.Errorf("file_with_prefix_not_found: %s in %s", prefix, dir)
}
func IsAbsPath(path string) bool {
return len(path) > 0 && os.IsPathSeparator(path[0])
}
func CreateOrTruncateFile(path string, size int64) error {
if !IsAbsPath(path) {
return fmt.Errorf("path must be absolute: %s", path)
}
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer f.Close()
if err := f.Truncate(size); err != nil {
return fmt.Errorf("failed to truncate file: %w", err)
}
return nil
}
func CreateOrResizeFile(path string, size int64) error {
if !IsAbsPath(path) {
return fmt.Errorf("path must be absolute: %s", path)
}
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("failed to open raw file: %w", err)
}
defer f.Close()
if err := f.Truncate(size); err != nil {
return fmt.Errorf("failed_to_resize_raw_file: %w", err)
}
return nil
}
func FileExists(path string) (bool, error) {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("stat %q: %w", path, err)
}
if info.IsDir() {
return false, fmt.Errorf("%q is a directory, not a file", path)
}
return true, nil
}
func ReadFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read file %q: %w", path, err)
}
return data, nil
}
func IsEmptyDir(path string) (bool, error) {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return false, fmt.Errorf("directory %q does not exist", path)
}
if err != nil {
return false, fmt.Errorf("stat %q: %w", path, err)
}
if !info.IsDir() {
return false, fmt.Errorf("%q is not a directory", path)
}
files, err := os.ReadDir(path)
if err != nil {
return false, fmt.Errorf("failed to read directory %q: %w", path, err)
}
return len(files) == 0, nil
}
func IsDir(path string) (bool, error) {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("stat %q: %w", path, err)
}
return info.IsDir(), nil
}
func CopyDirContents(source, destination string) error {
if _, err := os.Stat(destination); os.IsNotExist(err) {
if err := os.MkdirAll(destination, 0755); err != nil {
return fmt.Errorf("failed to create destination dir: %w", err)
}
}
_, err := RunCommand("cp", "-a", source+"/.", destination)
return err
}
func RemoveDirContents(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("failed_to_list_dir: %w", err)
}
for _, entry := range entries {
err = os.RemoveAll(filepath.Join(dir, entry.Name()))
if err != nil {
return fmt.Errorf("failed_to_remove %s: %w", entry.Name(), err)
}
}
return nil
}
func DoesPathHaveBase(root string) (bool, error) {
if root == "" {
return false, fmt.Errorf("path_required")
}
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return false, fmt.Errorf("path_does_not_exist: %s", root)
}
return false, err
}
if !info.IsDir() {
return false, fmt.Errorf("not_a_directory: %s", root)
}
required := []string{
"bin/freebsd-version",
"bin/sh",
"libexec/ld-elf.so.1",
"lib/libc.so.7",
}
for _, rel := range required {
if _, err := os.Stat(filepath.Join(root, rel)); err != nil {
return false, nil
}
}
return true, nil
}
func IsTarLike(path string, mime string) bool {
runHead := func(cmd string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c := exec.CommandContext(ctx, cmd, args...)
c.Stderr = io.Discard
stdout, err := c.StdoutPipe()
if err != nil {
return nil, err
}
if err := c.Start(); err != nil {
return nil, err
}
buf := make([]byte, 512)
n, readErr := io.ReadFull(stdout, buf)
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
_ = c.Process.Kill()
_ = c.Wait()
return nil, readErr
}
_ = stdout.Close()
_ = c.Process.Kill()
_ = c.Wait()
return buf[:n], nil
}
var hdr []byte
var err error
switch mime {
case "application/x-tar":
// Uncompressed tar: read first 512 directly
f, e := os.Open(path)
if e != nil {
return false
}
defer f.Close()
hdr = make([]byte, 512)
n, e := io.ReadFull(f, hdr)
if e != nil || n < 512 {
return false
}
case "application/gzip":
// gzip -> gunzip header only
hdr, err = runHead("gzip", "-dc", path)
if err != nil || len(hdr) < 512 {
return false
}
case "application/x-bzip2":
hdr, err = runHead("bzip2", "-dc", path)
if err != nil || len(hdr) < 512 {
return false
}
case "application/x-xz":
hdr, err = runHead("xz", "-dc", path)
if err != nil || len(hdr) < 512 {
return false
}
case "application/zstd":
// bsdtar/libarchive recognizes zstd as “zstd”; many file detectors use application/zstd
hdr, err = runHead("zstd", "-dc", path)
if err != nil || len(hdr) < 512 {
return false
}
case "application/x-compress":
// legacy .Z
hdr, err = runHead("uncompress", "-c", path)
if err != nil || len(hdr) < 512 {
return false
}
default:
// Unknown/other: not a tar we can assert
return false
}
// TAR magic at bytes 257..262
if len(hdr) >= 263 {
m := string(hdr[257:263]) // "ustar\000" or "ustar "
return m == "ustar\x00" || m == "ustar "
}
return false
}
func StreamToFile(cmd []string, outPath string) error {
c := exec.Command(cmd[0], cmd[1:]...)
var stderr bytes.Buffer
c.Stderr = &stderr
stdout, err := c.StdoutPipe()
if err != nil {
return err
}
if err := c.Start(); err != nil {
return err
}
f, err := os.Create(outPath)
if err != nil {
_ = c.Process.Kill()
_ = c.Wait()
return err
}
defer f.Close()
buf := make([]byte, 512*1024)
if _, err := io.CopyBuffer(f, stdout, buf); err != nil {
_ = c.Process.Kill()
_ = c.Wait()
return err
}
err = c.Wait()
if err != nil {
out := stderr.String()
if strings.Contains(out, "trailing garbage ignored") {
return nil
}
return fmt.Errorf("decompress failed (StreamToFile): %w (%s)", err, out)
}
return nil
}
func SniffMIME(path string) (string, types.Type, error) {
f, err := os.Open(path)
if err != nil {
return "", filetype.Unknown, err
}
defer f.Close()
head := make([]byte, 4096)
n, _ := io.ReadFull(f, head)
kind, _ := filetype.Match(head[:n])
if kind == filetype.Unknown {
return "", kind, fmt.Errorf("unknown format")
}
return kind.MIME.Value, kind, nil
}
func ResetDir(dir string) error {
if _, err := os.Stat(dir); err == nil {
_, _ = RunCommand("chflags", "-R", "noschg", dir)
if err := os.RemoveAll(dir); err != nil {
return err
}
}
return os.MkdirAll(dir, 0o755)
}
func DecompressOne(mime, src, out string) error {
var cmd []string
switch mime {
case "application/gzip":
if HasCmd("pigz") {
cmd = []string{"pigz", "-dc", src}
} else {
cmd = []string{"gzip", "-dc", src}
}
case "application/x-bzip2":
if HasCmd("pbzip2") {
cmd = []string{"pbzip2", "-dc", src}
} else {
cmd = []string{"bzip2", "-dc", src}
}
case "application/x-xz":
cmd = []string{"xz", "-T0", "-dc", src}
case "application/zstd":
cmd = []string{"zstd", "-T0", "-dc", src}
case "application/x-compress":
cmd = []string{"uncompress", "-c", src}
default:
return fmt.Errorf("unsupported compressed mime: %s", mime)
}
return StreamToFile(cmd, out)
}
func HasCmd(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func IsFileInDirectory(filePath, dirPath string) (bool, error) {
absFilePath, err := filepath.Abs(filePath)
if err != nil {
return false, fmt.Errorf("failed_to_get_absolute_file_path: %w", err)
}
absDirPath, err := filepath.Abs(dirPath)
if err != nil {
return false, fmt.Errorf("failed_to_get_absolute_dir_path: %w", err)
}
relPath, err := filepath.Rel(absDirPath, absFilePath)
if err != nil {
return false, fmt.Errorf("failed_to_get_relative_path: %w", err)
}
if relPath == "." || relPath == ".." || relPath[:3] == ".."+string(os.PathSeparator) {
return false, nil
}
return true, nil
}
const (
defaultBlockSize = "1M"
defaultQueueDepth = 8
)
func FlashImageToDiskCtx(ctx context.Context, source, dest string) error {
if source == "" || dest == "" {
return fmt.Errorf("source and dest must be non-empty")
}
if source == dest {
return fmt.Errorf("source and dest must not be the same path")
}
srcInfo, err := os.Stat(source)
if err != nil {
return fmt.Errorf("stat source %q: %w", source, err)
}
if srcInfo.IsDir() {
return fmt.Errorf("source %q is a directory, expected a file", source)
}
if _, err := exec.LookPath("camdd"); err != nil {
return fmt.Errorf("camdd not found in PATH: %w", err)
}
inArg := fmt.Sprintf("file=%s,bs=%s", source, defaultBlockSize)
var outArg string
if strings.HasPrefix(dest, "/dev/") {
if _, err := os.Stat(dest); err != nil {
return fmt.Errorf("destination device %q not found or not accessible: %w", dest, err)
}
if strings.HasPrefix(dest, "/dev/pass") {
// True pass(4) device
outArg = fmt.Sprintf("pass=%s,bs=%s,depth=%d", dest, defaultBlockSize, defaultQueueDepth)
} else {
// zvols, /dev/da0, /dev/nvd0, /dev/md*, etc.
outArg = fmt.Sprintf("file=%s,bs=%s", dest, defaultBlockSize)
}
} else {
if info, err := os.Stat(dest); err == nil && info.IsDir() {
return fmt.Errorf("destination %q is a directory, expected a file", dest)
}
if !filepath.IsAbs(dest) {
if abs, err := filepath.Abs(dest); err == nil {
dest = abs
}
}
outArg = fmt.Sprintf("file=%s,bs=%s", dest, defaultBlockSize)
}
args := []string{"-i", inArg, "-o", outArg}
cmd := exec.CommandContext(ctx, "camdd", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return fmt.Errorf("camdd canceled or timed out: %w", ctx.Err())
}
return fmt.Errorf("camdd failed: %w", err)
}
return nil
}
func FlashImageToDisk(source, dest string) error {
return FlashImageToDiskCtx(context.Background(), source, dest)
}
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp)
if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Chmod(tmp, perm); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
return nil
}
func AtomicAppendFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
var existing []byte
if b, err := os.ReadFile(path); err == nil {
existing = b
} else if !os.IsNotExist(err) {
return err
}
f, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp)
if _, err := f.Write(existing); err != nil {
f.Close()
return err
}
if len(existing) > 0 && existing[len(existing)-1] != '\n' {
if _, err := f.Write([]byte("\n")); err != nil {
f.Close()
return err
}
}
if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Chmod(tmp, perm); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
return nil
}
func ReadLastLines(path string, maxLines int) (string, error) {
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", err
}
defer f.Close()
const chunkSize = 4096
var (
offset int64
fileSize int64
lineCount int
buffer []byte
)
info, err := f.Stat()
if err != nil {
return "", err
}
fileSize = info.Size()
offset = fileSize
for offset > 0 && lineCount <= maxLines {
readSize := int64(chunkSize)
if offset < readSize {
readSize = offset
}
offset -= readSize
chunk := make([]byte, readSize)
_, err := f.ReadAt(chunk, offset)
if err != nil && err != io.EOF {
return "", err
}
buffer = append(chunk, buffer...)
for i := len(chunk) - 1; i >= 0; i-- {
if chunk[i] == '\n' {
lineCount++
if lineCount > maxLines {
return string(buffer[i+1:]), nil
}
}
}
}
return string(buffer), nil
}