package backup import ( "archive/tar" "archive/zip" "bytes" "context" "crypto/sha256" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "hash" "io" "os" "path" "path/filepath" "regexp" "strings" "github.com/aritmolab/thothii/tools/tht/internal/config" ) var archiveDrivePath = regexp.MustCompile(`^[A-Za-z]:/`) // PreflightRequest identifies an archive and the explicit protections required to inspect a // restore that contains external secret payloads. Preflight never writes to the installation. type PreflightRequest struct { Archive string Confirm bool AllowExternalSecrets bool Limits PreflightLimits } // PreflightLimits bounds hostile archive processing. Zero values select the conservative // defaults; callers that need larger installation backups must opt in explicitly. type PreflightLimits struct { MaxMembers int MaxArchiveBytes uint64 MaxUncompressedBytes uint64 MaxCompressionRatio uint64 } const ( defaultPreflightMaxMembers = 10_000 defaultPreflightMaxArchiveBytes = 32 << 30 defaultPreflightMaxUncompressedBytes = 128 << 30 defaultPreflightMaxCompressionRatio = 100 ) // PreflightDependencies supplies checks that require knowledge of the current Docker targets. // Part B can bind these callbacks to read-only Docker Compose and filesystem inspections before // it starts its restore transaction. type PreflightDependencies struct { FreeBytes func(target string) (uint64, error) CheckOwnershipPermissions func(context.Context, config.Installation, Manifest) error CheckVolumeMapping func(context.Context, config.Installation, Manifest) error CheckImageConfigCompatibility func(context.Context, config.Installation, Manifest) error } // ArchiveEntryMetadata is safe restore metadata. It intentionally contains no archive payload. type ArchiveEntryMetadata struct { Path string Kind string Owner string LogicalName string SourcePath string Size int64 SHA256 string Mode uint32 Sensitive bool } // PreflightResult is the validated, non-mutating input for a future restore transaction. // Manifest and Entries contain checksums and ownership metadata only; no secret or other // archive bytes are returned. type PreflightResult struct { ArchivePath string ArchiveSize int64 RequiredBytes uint64 Manifest Manifest Entries []ArchiveEntryMetadata archive *verifiedArchive stagingRoot string freeBytes func(string) (uint64, error) } type verifiedArchive struct { file *os.File info os.FileInfo digest string limits PreflightLimits } // stagedArchive holds an installation-private, immutable copy of the exact bytes accepted by // Preflight. The original archive remains retained only for provenance revalidation. type stagedArchive struct { file *os.File path string directory string } type inspectedArchiveEntry struct { metadata ArchiveEntryMetadata member *zip.File } // Preflight validates an archive completely before restore mutation. It streams each archive // member once for checksum verification and never extracts a member to a destination. func Preflight(ctx context.Context, installation config.Installation, request PreflightRequest, dependencies PreflightDependencies) (PreflightResult, error) { if dependencies.FreeBytes == nil || dependencies.CheckOwnershipPermissions == nil || dependencies.CheckVolumeMapping == nil || dependencies.CheckImageConfigCompatibility == nil { return PreflightResult{}, errors.New("backup preflight dependencies are incomplete") } if err := contextError(ctx); err != nil { return PreflightResult{}, err } limits, err := normalizePreflightLimits(request.Limits) if err != nil { return PreflightResult{}, err } archivePath, archiveInfo, err := openableArchivePath(request.Archive) if err != nil { return PreflightResult{}, err } if uint64(archiveInfo.Size()) > limits.MaxArchiveBytes { return PreflightResult{}, errors.New("backup archive exceeds the configured archive-size limit") } archiveFile, err := os.Open(archivePath) if err != nil { return PreflightResult{}, fmt.Errorf("open backup archive: %w", err) } keepArchiveOpen := false defer func() { if !keepArchiveOpen { _ = archiveFile.Close() } }() openedInfo, err := archiveFile.Stat() if err != nil { return PreflightResult{}, fmt.Errorf("inspect opened backup archive: %w", err) } if !openedInfo.Mode().IsRegular() || openedInfo.Size() != archiveInfo.Size() { return PreflightResult{}, errors.New("backup archive changed while opening") } memberCount, err := zipMemberCount(archiveFile, openedInfo.Size()) if err != nil { return PreflightResult{}, err } if memberCount > uint64(limits.MaxMembers) { return PreflightResult{}, errors.New("backup archive exceeds the configured member limit") } initialDigest, err := digestArchive(ctx, archiveFile, limits.MaxArchiveBytes) if err != nil { return PreflightResult{}, err } reader, err := zip.NewReader(archiveFile, openedInfo.Size()) if err != nil { return PreflightResult{}, fmt.Errorf("read backup archive: %w", err) } manifestBytes, entries, err := inspectArchiveMembers(ctx, reader, limits) if err != nil { return PreflightResult{}, err } if err := validateRawManifestPaths(manifestBytes); err != nil { return PreflightResult{}, err } manifest, err := DecodeManifest(manifestBytes) if err != nil { return PreflightResult{}, fmt.Errorf("validate backup manifest: %w", err) } expectedID, err := backupInstallationID(installation) if err != nil { return PreflightResult{}, err } if manifest.InstallationID != expectedID { return PreflightResult{}, errors.New("backup archive belongs to a different installation") } if manifest.IncludesSecrets && (!request.Confirm || !request.AllowExternalSecrets) { return PreflightResult{}, errors.New("secret-bearing backup requires explicit confirmation and external-secret permission") } metadata, requiredBytes, err := reconcileArchiveEntries(ctx, manifest, entries) if err != nil { return PreflightResult{}, err } stagingBytes := uint64(openedInfo.Size()) if stagingBytes > ^uint64(0)-requiredBytes { return PreflightResult{}, errors.New("restore staging requirement exceeds supported size") } requiredWithStaging := requiredBytes + stagingBytes stagingCapacityPath := installation.ControlDirectory() freeBytes, err := dependencies.FreeBytes(stagingCapacityPath) if err != nil { return PreflightResult{}, fmt.Errorf("check free disk space: %w", err) } if freeBytes < requiredWithStaging { return PreflightResult{}, fmt.Errorf("insufficient free disk space for restore: need %d bytes, have %d", requiredWithStaging, freeBytes) } if err := contextError(ctx); err != nil { return PreflightResult{}, err } if err := dependencies.CheckOwnershipPermissions(ctx, installation, manifest); err != nil { return PreflightResult{}, fmt.Errorf("validate target ownership and permissions: %w", err) } if err := dependencies.CheckVolumeMapping(ctx, installation, manifest); err != nil { return PreflightResult{}, fmt.Errorf("validate volume mapping: %w", err) } if err := dependencies.CheckImageConfigCompatibility(ctx, installation, manifest); err != nil { return PreflightResult{}, fmt.Errorf("validate image/config compatibility: %w", err) } finalDigest, err := digestArchive(ctx, archiveFile, limits.MaxArchiveBytes) if err != nil { return PreflightResult{}, err } if initialDigest != finalDigest { return PreflightResult{}, errors.New("backup archive changed during preflight") } keepArchiveOpen = true return PreflightResult{ ArchivePath: archivePath, ArchiveSize: openedInfo.Size(), RequiredBytes: requiredBytes, Manifest: manifest, Entries: metadata, archive: &verifiedArchive{file: archiveFile, info: openedInfo, digest: finalDigest, limits: limits}, stagingRoot: filepath.Join(stagingCapacityPath, "restore-staging"), freeBytes: dependencies.FreeBytes, }, nil } // RevalidateArchive binds a restore to the bytes inspected by Preflight. A caller must invoke // it immediately before a restore transaction and use the returned retained handle, never reopen // ArchivePath. It refuses a path replacement or in-place content change. func (result PreflightResult) RevalidateArchive() (*os.File, error) { return result.revalidateArchive(context.Background()) } func (result PreflightResult) revalidateArchive(ctx context.Context) (*os.File, error) { if result.archive == nil || result.archive.file == nil { return nil, errors.New("backup archive has not been retained by preflight") } pathInfo, err := os.Lstat(result.ArchivePath) if err != nil { return nil, errors.New("backup archive changed after preflight") } if !pathInfo.Mode().IsRegular() || !os.SameFile(result.archive.info, pathInfo) { return nil, errors.New("backup archive changed after preflight") } heldInfo, err := result.archive.file.Stat() if err != nil || !os.SameFile(result.archive.info, heldInfo) || heldInfo.Size() != result.ArchiveSize { return nil, errors.New("backup archive changed after preflight") } digest, err := digestArchive(ctx, result.archive.file, result.archive.limits.MaxArchiveBytes) if err != nil || digest != result.archive.digest { return nil, errors.New("backup archive changed after preflight") } return result.archive.file, nil } // StageArchive revalidates the retained archive and copies its exact bytes into a private file // immediately before extraction. Later writes to the source archive cannot affect extraction. func (result PreflightResult) StageArchive(ctx context.Context) (_ *stagedArchive, resultErr error) { source, err := result.revalidateArchive(ctx) if err != nil { return nil, err } if result.stagingRoot == "" || result.freeBytes == nil { return nil, errors.New("backup archive has no controlled staging reservation") } if err := ensurePrivateStagingRoot(result.stagingRoot); err != nil { return nil, fmt.Errorf("create private restore staging root: %w", err) } freeBytes, err := result.freeBytes(result.stagingRoot) if err != nil { return nil, errors.New("check private restore staging capacity") } if result.ArchiveSize < 0 || freeBytes < uint64(result.ArchiveSize) { return nil, errors.New("insufficient free disk space for private restore staging archive") } directory, err := os.MkdirTemp(result.stagingRoot, "archive-") if err != nil { return nil, errors.New("create private restore staging directory") } if err := os.Chmod(directory, 0o700); err != nil { _ = os.Remove(directory) return nil, errors.New("protect private restore staging directory") } path := filepath.Join(directory, "archive.zip") file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { _ = os.Remove(directory) return nil, errors.New("create private restore staging archive") } staged := &stagedArchive{file: file, path: path, directory: directory} completed := false defer func() { if !completed { _ = staged.Close() } }() if _, err := source.Seek(0, io.SeekStart); err != nil { return nil, errors.New("seek verified backup archive for staging") } digest := sha256.New() buffer := make([]byte, 128*1024) var total int64 for { if err := contextError(ctx); err != nil { return nil, err } count, readErr := source.Read(buffer) if count > 0 { if int64(count) > result.ArchiveSize-total { return nil, errors.New("backup archive changed after preflight") } written, writeErr := file.Write(buffer[:count]) if writeErr != nil || written != count { return nil, errors.New("write private restore staging archive") } if _, writeErr := digest.Write(buffer[:count]); writeErr != nil { return nil, errors.New("hash private restore staging archive") } total += int64(count) } if errors.Is(readErr, io.EOF) { break } if readErr != nil { return nil, errors.New("read verified backup archive for staging") } } if total != result.ArchiveSize || digestForHash(digest) != result.archive.digest { return nil, errors.New("backup archive changed after preflight") } if err := file.Sync(); err != nil { return nil, errors.New("sync private restore staging archive") } if _, err := file.Seek(0, io.SeekStart); err != nil { return nil, errors.New("rewind private restore staging archive") } completed = true return staged, nil } func ensurePrivateStagingRoot(root string) error { if !filepath.IsAbs(root) || filepath.Clean(root) != root { return errors.New("restore staging root is invalid") } if err := os.Mkdir(root, 0o700); err != nil && !errors.Is(err, os.ErrExist) { return errors.New("restore staging root is unavailable") } info, err := os.Lstat(root) if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return errors.New("restore staging root is unsafe") } if err := os.Chmod(root, 0o700); err != nil { return errors.New("restore staging root cannot be protected") } info, err = os.Lstat(root) if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0o700 { return errors.New("restore staging root protection is invalid") } return nil } // Close removes only the staging file and directory created by StageArchive. func (staged *stagedArchive) Close() error { if staged == nil { return nil } var failed bool if staged.file != nil { if err := staged.file.Close(); err != nil { failed = true } staged.file = nil } if staged.path != "" { if err := os.Remove(staged.path); err != nil && !errors.Is(err, os.ErrNotExist) { failed = true } staged.path = "" } if staged.directory != "" { if err := os.Remove(staged.directory); err != nil && !errors.Is(err, os.ErrNotExist) { failed = true } staged.directory = "" } if failed { return errors.New("destroy private restore staging archive") } return nil } // CloseArchive releases the retained read-only archive handle after the caller finishes the // restore transaction or decides not to proceed. func (result PreflightResult) CloseArchive() error { if result.archive == nil || result.archive.file == nil { return nil } return result.archive.file.Close() } func normalizePreflightLimits(requested PreflightLimits) (PreflightLimits, error) { if requested.MaxMembers < 0 { return PreflightLimits{}, errors.New("backup preflight member limit must be positive") } if requested.MaxMembers == 0 { requested.MaxMembers = defaultPreflightMaxMembers } if requested.MaxArchiveBytes == 0 { requested.MaxArchiveBytes = defaultPreflightMaxArchiveBytes } if requested.MaxUncompressedBytes == 0 { requested.MaxUncompressedBytes = defaultPreflightMaxUncompressedBytes } if requested.MaxCompressionRatio == 0 { requested.MaxCompressionRatio = defaultPreflightMaxCompressionRatio } return requested, nil } func validateRawManifestPaths(value []byte) error { var raw struct { Entries []struct { Path string `json:"path"` } `json:"entries"` } decoder := json.NewDecoder(bytes.NewReader(value)) if err := decoder.Decode(&raw); err != nil { return fmt.Errorf("validate backup manifest paths: %w", err) } for _, entry := range raw.Entries { if _, err := validateArchiveMemberPath(entry.Path); err != nil { return fmt.Errorf("validate backup manifest entry path: %w", err) } } return nil } func openableArchivePath(requested string) (string, os.FileInfo, error) { if strings.TrimSpace(requested) == "" { return "", nil, errors.New("backup archive path is required") } archivePath, err := filepath.Abs(requested) if err != nil { return "", nil, fmt.Errorf("resolve backup archive path: %w", err) } archivePath = filepath.Clean(archivePath) info, err := os.Lstat(archivePath) if err != nil { return "", nil, fmt.Errorf("inspect backup archive: %w", err) } if !info.Mode().IsRegular() { return "", nil, errors.New("backup archive must be a regular file") } return archivePath, info, nil } func inspectArchiveMembers(ctx context.Context, reader *zip.Reader, limits PreflightLimits) ([]byte, map[string]inspectedArchiveEntry, error) { if len(reader.File) > limits.MaxMembers { return nil, nil, errors.New("backup archive exceeds the configured member limit") } var manifestBytes []byte entries := make(map[string]inspectedArchiveEntry, len(reader.File)) var totalUncompressed uint64 for _, member := range reader.File { if err := contextError(ctx); err != nil { return nil, nil, err } if err := checkArchiveMemberLimits(member, limits, &totalUncompressed); err != nil { return nil, nil, err } name, err := validateArchiveMemberPath(member.Name) if err != nil { return nil, nil, err } if _, exists := entries[name]; exists || name == ManifestPath && manifestBytes != nil { return nil, nil, fmt.Errorf("backup archive contains duplicate entry %q", name) } if member.Mode()&os.ModeSymlink != 0 { return nil, nil, fmt.Errorf("backup archive contains a symlink entry %q", name) } if member.FileInfo().IsDir() { return nil, nil, fmt.Errorf("backup archive contains unsupported directory entry %q", name) } opened, err := member.Open() if err != nil { return nil, nil, fmt.Errorf("open backup archive entry: %w", err) } if name == ManifestPath { manifestBytes, err = io.ReadAll(io.LimitReader(opened, maxPreflightManifestBytes+1)) closeErr := opened.Close() if err != nil { return nil, nil, fmt.Errorf("read backup manifest: %w", err) } if closeErr != nil { return nil, nil, fmt.Errorf("close backup manifest: %w", closeErr) } if int64(len(manifestBytes)) > maxPreflightManifestBytes { return nil, nil, errors.New("backup manifest is too large") } if uint64(len(manifestBytes)) != member.UncompressedSize64 { return nil, nil, errors.New("backup manifest size does not match its archive metadata") } continue } hashValue := sha256.New() size, copyErr := io.Copy(hashValue, io.LimitReader(opened, int64(member.UncompressedSize64)+1)) closeErr := opened.Close() if copyErr != nil { return nil, nil, fmt.Errorf("read backup archive entry: %w", copyErr) } if closeErr != nil { return nil, nil, fmt.Errorf("close backup archive entry: %w", closeErr) } if size < 0 { return nil, nil, errors.New("backup archive entry size overflow") } if uint64(size) != member.UncompressedSize64 { return nil, nil, errors.New("backup archive entry size does not match its archive metadata") } entries[name] = inspectedArchiveEntry{ metadata: ArchiveEntryMetadata{Path: name, Size: size, SHA256: digestForHash(hashValue), Mode: uint32(member.Mode().Perm())}, member: member, } } if manifestBytes == nil { return nil, nil, errors.New("backup archive is missing manifest.json") } return manifestBytes, entries, nil } func checkArchiveMemberLimits(member *zip.File, limits PreflightLimits, total *uint64) error { if member.UncompressedSize64 > limits.MaxUncompressedBytes || *total > limits.MaxUncompressedBytes-member.UncompressedSize64 { return errors.New("backup archive exceeds the configured uncompressed-size limit") } *total += member.UncompressedSize64 if member.CompressedSize64 == 0 { if member.UncompressedSize64 != 0 { return errors.New("backup archive exceeds the configured compression-ratio limit") } return nil } if limits.MaxCompressionRatio <= ^uint64(0)/member.CompressedSize64 && member.UncompressedSize64 > limits.MaxCompressionRatio*member.CompressedSize64 { return errors.New("backup archive exceeds the configured compression-ratio limit") } return nil } func digestArchive(ctx context.Context, file *os.File, maximum uint64) (string, error) { if _, err := file.Seek(0, io.SeekStart); err != nil { return "", fmt.Errorf("seek backup archive: %w", err) } digest := sha256.New() buffer := make([]byte, 128*1024) var total uint64 for { if err := contextError(ctx); err != nil { return "", err } count, err := file.Read(buffer) if count > 0 { if uint64(count) > maximum-total { return "", errors.New("backup archive exceeds the configured archive-size limit") } total += uint64(count) if _, writeErr := digest.Write(buffer[:count]); writeErr != nil { return "", fmt.Errorf("hash backup archive: %w", writeErr) } } if errors.Is(err, io.EOF) { break } if err != nil { return "", fmt.Errorf("read backup archive: %w", err) } } if _, err := file.Seek(0, io.SeekStart); err != nil { return "", fmt.Errorf("rewind backup archive: %w", err) } return digestForHash(digest), nil } func zipMemberCount(file *os.File, size int64) (uint64, error) { const ( endOfCentralDirectorySignature = 0x06054b50 zip64LocatorSignature = 0x07064b50 zip64EndSignature = 0x06064b50 endOfCentralDirectorySize = 22 zipCommentMaximum = 1<<16 - 1 zip64LocatorSize = 20 zip64EndMinimumSize = 56 ) if size < endOfCentralDirectorySize { return 0, errors.New("read backup archive directory: archive is too small") } tailSize := int64(endOfCentralDirectorySize + zipCommentMaximum) if size < tailSize { tailSize = size } tail := make([]byte, tailSize) if _, err := file.ReadAt(tail, size-tailSize); err != nil { return 0, fmt.Errorf("read backup archive directory: %w", err) } for index := len(tail) - endOfCentralDirectorySize; index >= 0; index-- { if binary.LittleEndian.Uint32(tail[index:index+4]) != endOfCentralDirectorySignature { continue } commentLength := int(binary.LittleEndian.Uint16(tail[index+20 : index+22])) if index+endOfCentralDirectorySize+commentLength != len(tail) { continue } count := uint64(binary.LittleEndian.Uint16(tail[index+10 : index+12])) if count != 0xffff { return count, nil } endOffset := size - tailSize + int64(index) if endOffset < zip64LocatorSize { return 0, errors.New("read backup archive directory: ZIP64 locator is missing") } locator := make([]byte, zip64LocatorSize) if _, err := file.ReadAt(locator, endOffset-zip64LocatorSize); err != nil { return 0, fmt.Errorf("read backup archive directory: %w", err) } if binary.LittleEndian.Uint32(locator[:4]) != zip64LocatorSignature { return 0, errors.New("read backup archive directory: ZIP64 locator is invalid") } zip64Offset := binary.LittleEndian.Uint64(locator[8:16]) if size < zip64EndMinimumSize || zip64Offset > uint64(size-zip64EndMinimumSize) { return 0, errors.New("read backup archive directory: ZIP64 record is invalid") } zip64End := make([]byte, zip64EndMinimumSize) if _, err := file.ReadAt(zip64End, int64(zip64Offset)); err != nil { return 0, fmt.Errorf("read backup archive directory: %w", err) } if binary.LittleEndian.Uint32(zip64End[:4]) != zip64EndSignature || binary.LittleEndian.Uint64(zip64End[4:12]) < 44 { return 0, errors.New("read backup archive directory: ZIP64 record is invalid") } return binary.LittleEndian.Uint64(zip64End[32:40]), nil } return 0, errors.New("read backup archive directory: end record is missing") } const maxPreflightManifestBytes = 16 << 20 func validateArchiveMemberPath(value string) (string, error) { if value == "" || strings.ContainsRune(value, '\x00') { return "", errors.New("backup archive contains an invalid empty or NUL path") } if strings.ContainsRune(value, '\\') { return "", fmt.Errorf("backup archive entry path %q uses an unsafe separator", value) } if strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || archiveDrivePath.MatchString(value) { return "", fmt.Errorf("backup archive entry path %q is absolute", value) } clean := path.Clean(value) if clean != value || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { return "", fmt.Errorf("backup archive entry path %q escapes the archive", value) } return clean, nil } func reconcileArchiveEntries(ctx context.Context, manifest Manifest, entries map[string]inspectedArchiveEntry) ([]ArchiveEntryMetadata, uint64, error) { metadata := make([]ArchiveEntryMetadata, 0, len(entries)) var requiredBytes uint64 for _, entry := range manifest.Entries { actual, present := entries[entry.Path] if entry.Archived && !present { return nil, 0, fmt.Errorf("backup archive is missing entry %q", entry.Path) } if !entry.Archived { if present { return nil, 0, fmt.Errorf("non-archived backup entry %q has a payload", entry.Path) } continue } if actual.metadata.Size != entry.Size || actual.metadata.SHA256 != entry.SHA256 { return nil, 0, fmt.Errorf("checksum or size mismatch for backup entry %q", entry.Path) } if actual.metadata.Mode != entry.Mode { return nil, 0, fmt.Errorf("mode mismatch for backup entry %q", entry.Path) } if entry.Kind == EntryVolume { if err := validateVolumeTar(ctx, actual.member); err != nil { return nil, 0, fmt.Errorf("validate volume archive %q: %w", entry.Path, err) } } if ^uint64(0)-requiredBytes < uint64(actual.metadata.Size) { return nil, 0, errors.New("backup archive size overflows free-space calculation") } requiredBytes += uint64(actual.metadata.Size) actual.metadata.Kind = entry.Kind actual.metadata.Owner = entry.Owner actual.metadata.LogicalName = entry.LogicalName actual.metadata.SourcePath = entry.SourcePath actual.metadata.Sensitive = entry.Sensitive metadata = append(metadata, actual.metadata) delete(entries, entry.Path) } if len(entries) != 0 { for name := range entries { return nil, 0, fmt.Errorf("backup archive contains unmanifested entry %q", name) } } return metadata, requiredBytes, nil } func validateVolumeTar(ctx context.Context, member *zip.File) error { if member == nil { return errors.New("volume archive member is unavailable") } opened, err := member.Open() if err != nil { return fmt.Errorf("open volume archive: %w", err) } defer opened.Close() reader := tar.NewReader(opened) for { if err := contextError(ctx); err != nil { return err } header, err := reader.Next() if errors.Is(err, io.EOF) { return nil } if err != nil { return fmt.Errorf("read volume archive: %w", err) } name := strings.TrimSuffix(header.Name, "/") if name == "." { if header.Typeflag != tar.TypeDir { return errors.New("volume archive root marker is not a directory") } continue } name = strings.TrimPrefix(name, "./") if _, err := validateArchiveMemberPath(name); err != nil { return fmt.Errorf("volume archive path is unsafe: %w", err) } switch header.Typeflag { case tar.TypeSymlink, tar.TypeLink: return fmt.Errorf("volume archive contains a link entry %q", name) case tar.TypeChar, tar.TypeBlock, tar.TypeFifo: return fmt.Errorf("volume archive contains a special entry %q", name) } } } func digestForHash(value hash.Hash) string { return "sha256:" + hex.EncodeToString(value.Sum(nil)) } func contextError(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() default: return nil } }