repos / dbin

📦 Poor man's package manager.
git clone https://github.com/xplshn/dbin.git

xplshn  ·  2025-08-13

fetch.go

Go
  1package main
  2
  3import (
  4	"context"
  5	"encoding/hex"
  6	"fmt"
  7	"io"
  8	"net/http"
  9	"os"
 10	"os/signal"
 11	"path/filepath"
 12	"regexp"
 13	"strings"
 14	"syscall"
 15	"time"
 16
 17	"github.com/goccy/go-json"
 18	"github.com/hedzr/progressbar"
 19	"github.com/jedisct1/go-minisign"
 20	"github.com/pkg/xattr"
 21	"github.com/zeebo/blake3"
 22	"github.com/zeebo/errs"
 23)
 24
 25var (
 26	errDownloadFailed   = errs.Class("download failed")
 27	errSignatureVerify  = errs.Class("signature verification failed")
 28	errChecksumMismatch = errs.Class("checksum mismatch")
 29	errOCIReference     = errs.Class("invalid OCI reference")
 30	errAuthToken        = errs.Class("failed to get auth token")
 31	errManifestDownload = errs.Class("failed to download manifest")
 32	errOCILayerDownload = errs.Class("failed to download OCI layer")
 33)
 34
 35type ociXAttrMeta struct {
 36	Offset int64  `json:"offset"`
 37	Digest string `json:"digest"`
 38}
 39
 40func getOCIMeta(path string) (ociXAttrMeta, error) {
 41	var meta ociXAttrMeta
 42	raw, err := xattr.Get(path, "user.dbin.ocichunk")
 43	if err != nil {
 44		return meta, err
 45	}
 46	return meta, json.Unmarshal(raw, &meta)
 47}
 48
 49func setOCIMeta(path string, offset int64, digest string) error {
 50	meta := ociXAttrMeta{Offset: offset, Digest: digest}
 51	raw, err := json.Marshal(meta)
 52	if err != nil {
 53		return err
 54	}
 55	xattr.Set(path, "user.dbin.ocichunk", raw)
 56	return nil
 57}
 58
 59func downloadWithProgress(ctx context.Context, bar progressbar.PB, resp *http.Response, destination string, bEntry *binaryEntry, isOCI bool, lastModified string, providedOffset int64) error {
 60	if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
 61		return errDownloadFailed.Wrap(err)
 62	}
 63
 64	tempFile := destination + ".tmp"
 65	resumeOffset := providedOffset
 66	if isOCI {
 67		if meta, err := getOCIMeta(tempFile); err == nil {
 68			resumeOffset = meta.Offset
 69		}
 70	}
 71
 72	out, err := openOrCreateFile(tempFile, resumeOffset)
 73	if err != nil {
 74		return errDownloadFailed.Wrap(err)
 75	}
 76	defer out.Close()
 77
 78	if !isOCI && lastModified != "" {
 79		xattr.Set(tempFile, "user.dbin.lastmod", []byte(lastModified))
 80	}
 81
 82	hash, err := initializeHash(tempFile, resumeOffset)
 83	if err != nil {
 84		return errDownloadFailed.Wrap(err)
 85	}
 86
 87	writer := setupWriter(out, hash, bar, resp, resumeOffset)
 88
 89	_, err = copyWithInterruption(ctx, writer, resp.Body, hash, tempFile, isOCI, resumeOffset)
 90	if err != nil {
 91		return err
 92	}
 93
 94	if err := cleanupMetadata(tempFile, isOCI); err != nil {
 95		return errDownloadFailed.Wrap(err)
 96	}
 97
 98	if err := verifyChecksum(hash, bEntry, tempFile); err != nil {
 99		return err
100	}
101
102	if err := validateFileType(tempFile); err != nil {
103		return err
104	}
105
106	if err := os.Rename(tempFile, destination); err != nil {
107		return errDownloadFailed.Wrap(err)
108	}
109
110	return os.Chmod(destination, 0755)
111}
112
113func openOrCreateFile(path string, offset int64) (*os.File, error) {
114	if offset > 0 {
115		out, err := os.OpenFile(path, os.O_RDWR, 0644)
116		if err != nil {
117			return nil, err
118		}
119		if _, err := out.Seek(offset, io.SeekStart); err != nil {
120			out.Close()
121			return nil, err
122		}
123		return out, nil
124	}
125	return os.Create(path)
126}
127
128func initializeHash(tempFile string, resumeOffset int64) (*blake3.Hasher, error) {
129	hash := blake3.New()
130	if resumeOffset > 0 {
131		rf, err := os.Open(tempFile)
132		if err != nil {
133			return nil, err
134		}
135		defer rf.Close()
136		_, err = io.CopyN(hash, rf, resumeOffset)
137		if err != nil {
138			return nil, err
139		}
140	}
141	return hash, nil
142}
143
144func setupWriter(out *os.File, hash *blake3.Hasher, bar progressbar.PB, resp *http.Response, resumeOffset int64) io.Writer {
145	if bar != nil {
146		writer := io.MultiWriter(out, hash, bar)
147		bar.UpdateRange(0, resp.ContentLength+resumeOffset)
148		bar.SetInitialValue(resumeOffset)
149		return writer
150	}
151	return io.MultiWriter(out, hash)
152}
153
154func copyWithInterruption(ctx context.Context, writer io.Writer, reader io.Reader, hash *blake3.Hasher, tempFile string, isOCI bool, startOffset int64) (int64, error) {
155	sigCh := make(chan os.Signal, 1)
156	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
157	defer signal.Stop(sigCh)
158
159	written := startOffset
160	buf := make([]byte, 64*1024)
161
162	for {
163		select {
164		case <-ctx.Done():
165			return written, ctx.Err()
166		case <-sigCh:
167			if isOCI {
168				setOCIMeta(tempFile, written, hex.EncodeToString(hash.Sum(nil)))
169			}
170			os.Exit(130)
171		default:
172		}
173
174		n, err := reader.Read(buf)
175		if n > 0 {
176			if _, errw := writer.Write(buf[:n]); errw != nil {
177				return written, errDownloadFailed.Wrap(errw)
178			}
179			written += int64(n)
180			if isOCI && written%524288 == 0 {
181				if err := setOCIMeta(tempFile, written, hex.EncodeToString(hash.Sum(nil))); err != nil {
182					return written, errDownloadFailed.Wrap(err)
183				}
184			}
185		}
186
187		if err == io.EOF {
188			break
189		}
190		if err != nil {
191			if isOCI {
192				setOCIMeta(tempFile, written, hex.EncodeToString(hash.Sum(nil)))
193			}
194			return written, errDownloadFailed.Wrap(err)
195		}
196	}
197
198	return written, nil
199}
200
201func cleanupMetadata(tempFile string, isOCI bool) error {
202	if isOCI {
203		xattr.Remove(tempFile, "user.dbin.ocichunk")
204		return nil
205	}
206	xattr.Remove(tempFile, "user.dbin.lastmod")
207	return nil
208}
209
210func verifyChecksum(hash *blake3.Hasher, bEntry *binaryEntry, destination string) error {
211	if bEntry.Bsum != "" && bEntry.Bsum != "!no_check" {
212		calculatedChecksum := hex.EncodeToString(hash.Sum(nil))
213		if calculatedChecksum != bEntry.Bsum {
214			fmt.Fprintf(os.Stderr, "expected %s, got %s\n", bEntry.Bsum, calculatedChecksum)
215			//os.Remove(destination)
216			//return errChecksumMismatch.New("expected %s, got %s", bEntry.Bsum, calculatedChecksum)
217		}
218	}
219	return nil
220}
221
222func validateFileType(filePath string) error {
223	file, err := os.Open(filePath)
224	if err != nil {
225		return errFileTypeInvalid.Wrap(err)
226	}
227	defer file.Close()
228
229	buf := make([]byte, 128)
230	n, err := file.Read(buf)
231	if err != nil && err != io.EOF {
232		return errFileTypeInvalid.Wrap(err)
233	}
234
235	// Check for ELF
236	if n >= 4 && string(buf[:4]) == "\x7fELF" {
237		return nil
238	}
239
240	content := string(buf[:n])
241	// Check for bad responses
242	if strings.HasPrefix(content, "<!DOCTYPE html>") || strings.HasPrefix(content, "<html>") {
243		return errFileTypeInvalid.New("file looks like HTML: %s", strings.TrimLeft(content, " \t\r\n"))
244	}
245	// Check for Nix Objects/Nix Garbage
246	if strings.HasPrefix(content, "#!") {
247		firstLine := content
248		if i := strings.IndexByte(content, '\n'); i >= 0 {
249			firstLine = content[:i]
250		}
251		if regexp.MustCompile(`^#!\s*/nix/store/[^/]+/`).MatchString(firstLine) {
252			return errFileTypeInvalid.New("file contains invalid shebang (nix object/garbage): [%s]", firstLine)
253		}
254		if strings.Count(content, "\n") < 5 {
255			return errFileTypeInvalid.New("file with shebang is less than 5 lines long. (nix object/garbage): \n---\n%s\n---", content)
256		}
257		return nil
258	}
259
260	return errFileTypeInvalid.New("file is neither a shell script nor an ELF. Please report this at @ https://github.com/xplshn/dbin")
261}
262
263func verifySignature(binaryPath string, sigData []byte, bEntry *binaryEntry, cfg *config) error {
264	pubKeyURL := bEntry.Repository.PubKeys[bEntry.Repository.Name]
265	if pubKeyURL == "" {
266		return nil
267	}
268
269	pubKeyData, err := accessCachedOrFetch([]string{pubKeyURL}, bEntry.Repository.Name+".minisign", cfg, bEntry.Repository.SyncInterval)
270	if err != nil {
271		return errSignatureVerify.Wrap(err)
272	}
273
274	pubKey, err := minisign.NewPublicKey(string(pubKeyData))
275	if err != nil {
276		return errSignatureVerify.Wrap(err)
277	}
278
279	sig, err := minisign.DecodeSignature(string(sigData))
280	if err != nil {
281		return errSignatureVerify.Wrap(err)
282	}
283
284	binaryData, err := os.ReadFile(binaryPath)
285	if err != nil {
286		return errSignatureVerify.Wrap(err)
287	}
288
289	verified, err := pubKey.Verify(binaryData, sig)
290	if err != nil {
291		return errSignatureVerify.Wrap(err)
292	}
293	if !verified {
294		return errSignatureVerify.New("signature is invalid")
295	}
296	return nil
297}
298
299func createHTTPRequest(ctx context.Context, method, url string) (*http.Request, error) {
300	req, err := http.NewRequestWithContext(ctx, method, url, nil)
301	if err != nil {
302		return nil, err
303	}
304	req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
305	req.Header.Set("Pragma", "no-cache")
306	req.Header.Set("Expires", "0")
307	req.Header.Set("User-Agent", fmt.Sprintf("dbin/%.1f", version))
308
309	return req, nil
310}
311
312func fetchBinaryFromURLToDest(ctx context.Context, bar progressbar.PB, bEntry *binaryEntry, destination string, cfg *config) error {
313	if strings.HasPrefix(bEntry.DownloadURL, "oci://") {
314		bEntry.DownloadURL = strings.TrimPrefix(bEntry.DownloadURL, "oci://")
315		return fetchOCIImage(ctx, bar, bEntry, destination, cfg)
316	}
317
318	client := &http.Client{}
319
320	// Check for signature and license file existence
321	hasSignature, hasLicense, err := httpCheckSignatureAndLicense(ctx, client, bEntry.DownloadURL)
322	if err != nil {
323		return errDownloadFailed.Wrap(err)
324	}
325
326	resumeOffset, lastModified, err := checkPartialDownload(destination + ".tmp")
327	if err != nil {
328		return errDownloadFailed.Wrap(err)
329	}
330
331	// Validate resume capability
332	if err := validateResume(ctx, client, bEntry.DownloadURL); err != nil {
333		return err
334	}
335
336	resp, actualOffset, err := createDownloadRequest(ctx, client, bEntry.DownloadURL, resumeOffset, lastModified)
337	if err != nil {
338		return err
339	}
340	defer resp.Body.Close()
341
342	if err := downloadWithProgress(ctx, bar, resp, destination, bEntry, false, resp.Header.Get("Last-Modified"), actualOffset); err != nil {
343		return err
344	}
345
346	// Handle signature verification if signature exists
347	if hasSignature {
348		if err := handleSignatureVerification(bEntry, destination, cfg); err != nil {
349			return err
350		}
351	}
352
353	// Handle license file download if license exists and CreateLicenses is enabled
354	if hasLicense && cfg.CreateLicenses {
355		licenseDest := filepath.Join(cfg.LicenseDir, filepath.Base(destination)+".LICENSE")
356		licenseResp, err := http.Get(bEntry.DownloadURL + ".LICENSE")
357		if err != nil {
358			if verbosityLevel >= silentVerbosityWithErrors {
359				fmt.Fprintf(os.Stderr, "Warning: Failed to fetch license file for %s: %v\n", destination, err)
360			}
361			return nil
362		}
363		defer licenseResp.Body.Close()
364
365		if licenseResp.StatusCode != http.StatusOK {
366			if verbosityLevel >= silentVerbosityWithErrors {
367				fmt.Fprintf(os.Stderr, "Warning: License file request returned status %d\n", licenseResp.StatusCode)
368			}
369			return nil
370		}
371
372		if err := saveLicenseFile(ctx, licenseResp, licenseDest); err != nil {
373			if verbosityLevel >= silentVerbosityWithErrors {
374				fmt.Fprintf(os.Stderr, "Warning: Failed to save license file for %s: %v\n", destination, err)
375			}
376			return nil
377		}
378
379		xattr.Set(licenseDest, "user.dbin.binary", []byte(destination))
380		xattr.Set(destination, "user.dbin.license", []byte(licenseDest))
381
382		if verbosityLevel >= extraVerbose {
383			fmt.Printf("Saved license file for %s to %s\n", destination, licenseDest)
384		}
385	}
386
387	return nil
388}
389
390func checkPartialDownload(tempFile string) (int64, string, error) {
391	fi, err := os.Stat(tempFile)
392	if err != nil {
393		return 0, "", nil // No partial download
394	}
395
396	resumeOffset := fi.Size()
397	var lastModified string
398	if modTimeBytes, err := xattr.Get(tempFile, "user.dbin.lastmod"); err == nil {
399		lastModified = string(modTimeBytes)
400	}
401
402	return resumeOffset, lastModified, nil
403}
404
405func validateResume(ctx context.Context, client *http.Client, url string) error {
406	req, err := createHTTPRequest(ctx, "HEAD", url)
407	if err != nil {
408		return errDownloadFailed.Wrap(err)
409	}
410
411	resp, err := client.Do(req)
412	if err != nil {
413		return errDownloadFailed.Wrap(err)
414	}
415	resp.Body.Close()
416
417	return nil
418}
419
420func createDownloadRequest(ctx context.Context, client *http.Client, url string, resumeOffset int64, lastModified string) (*http.Response, int64, error) {
421	req, err := createHTTPRequest(ctx, "GET", url)
422	if err != nil {
423		return nil, 0, errDownloadFailed.Wrap(err)
424	}
425
426	if resumeOffset > 0 {
427		if lastModified != "" {
428			req.Header.Set("If-Range", lastModified)
429		}
430		req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
431	}
432
433	resp, err := client.Do(req)
434	if err != nil {
435		return nil, 0, errDownloadFailed.Wrap(err)
436	}
437
438	actualOffset := resumeOffset
439	// Reset if server sends full file instead of range
440	if resumeOffset > 0 && resp.StatusCode == http.StatusOK {
441		os.Remove(req.URL.Path + ".tmp")
442		actualOffset = 0
443	}
444
445	return resp, actualOffset, nil
446}
447
448func handleSignatureVerification(bEntry *binaryEntry, destination string, cfg *config) error {
449	pubKeyURL := bEntry.Repository.PubKeys[bEntry.Repository.Name]
450	if pubKeyURL == "" {
451		return nil
452	}
453
454	sigResp, err := http.Get(bEntry.DownloadURL + ".sig")
455	if err != nil {
456		return errSignatureVerify.Wrap(err)
457	}
458	defer sigResp.Body.Close()
459
460	if sigResp.StatusCode != http.StatusOK {
461		return errSignatureVerify.New("status code %d", sigResp.StatusCode)
462	}
463
464	sigData, err := io.ReadAll(sigResp.Body)
465	if err != nil {
466		return errSignatureVerify.Wrap(err)
467	}
468
469	if err := verifySignature(destination, sigData, bEntry, cfg); err != nil {
470		os.Remove(destination)
471		return err
472	}
473
474	return nil
475}
476
477func fetchOCIImage(ctx context.Context, bar progressbar.PB, bEntry *binaryEntry, destination string, cfg *config) error {
478	parts := strings.SplitN(bEntry.DownloadURL, ":", 2)
479	if len(parts) != 2 {
480		return errOCIReference.New("invalid OCI reference format")
481	}
482
483	image, tag := parts[0], parts[1]
484	registry, repository := parseImage(image)
485
486	token, err := getAuthToken(registry, repository)
487	if err != nil {
488		return err
489	}
490
491	manifest, err := downloadManifest(ctx, registry, repository, tag, token)
492	if err != nil {
493		return err
494	}
495
496	title := filepath.Base(destination)
497	binaryResp, sigResp, licenseResp, err := downloadOCILayer(ctx, registry, repository, manifest, token, title, destination+".tmp", cfg)
498	if err != nil {
499		return err
500	}
501	defer closeResponses(binaryResp, sigResp, licenseResp)
502
503	if err := downloadWithProgress(ctx, bar, binaryResp, destination, bEntry, true, "", 0); err != nil {
504		return err
505	}
506
507	if err := handleOCISignature(bEntry, destination, cfg, sigResp); err != nil {
508		return err
509	}
510
511	return handleOCILicense(cfg, licenseResp, title, destination)
512}
513
514func closeResponses(responses ...*http.Response) {
515	for _, resp := range responses {
516		if resp != nil {
517			resp.Body.Close()
518		}
519	}
520}
521
522func handleOCISignature(bEntry *binaryEntry, destination string, cfg *config, sigResp *http.Response) error {
523	if bEntry.Repository.PubKeys[bEntry.Repository.Name] == "" || sigResp == nil {
524		return nil
525	}
526
527	sigData, err := io.ReadAll(sigResp.Body)
528	if err != nil {
529		return errSignatureVerify.Wrap(err)
530	}
531
532	if err := verifySignature(destination, sigData, bEntry, cfg); err != nil {
533		os.Remove(destination)
534		return err
535	}
536
537	return nil
538}
539
540func handleOCILicense(cfg *config, licenseResp *http.Response, title, destination string) error {
541	if !cfg.CreateLicenses || licenseResp == nil {
542		return nil
543	}
544
545	licenseDest := filepath.Join(cfg.LicenseDir, title+".LICENSE")
546	if err := saveLicenseFile(context.Background(), licenseResp, licenseDest); err != nil {
547		if verbosityLevel >= silentVerbosityWithErrors {
548			fmt.Fprintf(os.Stderr, "Warning: Failed to save license file for %s: %v\n", title, err)
549		}
550		return nil
551	}
552
553	if verbosityLevel >= extraVerbose {
554		fmt.Printf("Saved license file for %s to %s\n", title, licenseDest)
555		xattr.Set(licenseDest, "user.dbin.binary", []byte(destination))
556		xattr.Set(destination, "user.dbin.license", []byte(licenseDest))
557	}
558
559	return nil
560}
561
562func parseImage(image string) (string, string) {
563	parts := strings.SplitN(image, "/", 2)
564	if len(parts) == 1 {
565		return "docker.io", "library/" + parts[0]
566	}
567	return parts[0], parts[1]
568}
569
570func getAuthToken(registry, repository string) (string, error) {
571	url := fmt.Sprintf("https://%s/token?service=%s&scope=repository:%s:pull", registry, registry, repository)
572	resp, err := http.Get(url)
573	if err != nil {
574		return "", errAuthToken.Wrap(err)
575	}
576	defer resp.Body.Close()
577
578	var tokenResponse struct {
579		Token string `json:"token"`
580	}
581	if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
582		return "", errAuthToken.Wrap(err)
583	}
584	return tokenResponse.Token, nil
585}
586
587func downloadManifest(ctx context.Context, registry, repository, version, token string) (map[string]any, error) {
588	url := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, version)
589	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
590	if err != nil {
591		return nil, errManifestDownload.Wrap(err)
592	}
593	req.Header.Set("Authorization", "Bearer "+token)
594	req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json")
595
596	resp, err := http.DefaultClient.Do(req)
597	if err != nil {
598		return nil, errManifestDownload.Wrap(err)
599	}
600	defer resp.Body.Close()
601
602	var manifest map[string]any
603	if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
604		return nil, errManifestDownload.Wrap(err)
605	}
606	return manifest, nil
607}
608
609func saveLicenseFile(ctx context.Context, resp *http.Response, destination string) error {
610	if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
611		return errDownloadFailed.Wrap(err)
612	}
613
614	data, err := io.ReadAll(resp.Body)
615	if err != nil {
616		return errDownloadFailed.Wrap(err)
617	}
618
619	tempFile := destination + ".tmp"
620	if err := os.WriteFile(tempFile, data, 0644); err != nil {
621		return errDownloadFailed.Wrap(err)
622	}
623
624	if err := os.Rename(tempFile, destination); err != nil {
625		return errDownloadFailed.Wrap(err)
626	}
627
628	return os.Chmod(destination, 0644)
629}
630
631func downloadOCILayer(ctx context.Context, registry, repository string, manifest map[string]interface{}, token, title, tmpPath string, cfg *config) (*http.Response, *http.Response, *http.Response, error) {
632	titleNoExt := strings.TrimSuffix(title, filepath.Ext(title))
633	layers, ok := manifest["layers"].([]interface{})
634	if !ok {
635		return nil, nil, nil, errOCILayerDownload.New("invalid manifest structure")
636	}
637
638	digests := findLayerDigests(layers, title, titleNoExt)
639	if digests.binary == "" {
640		return nil, nil, nil, errOCILayerDownload.New("file with title '%s' not found in manifest", title)
641	}
642
643	binaryResp, err := downloadOCIBlob(ctx, registry, repository, digests.binary, token, tmpPath)
644	if err != nil {
645		return nil, nil, nil, err
646	}
647
648	var sigResp, licenseResp *http.Response
649	if digests.signature != "" {
650		sigResp, err = downloadOCIBlob(ctx, registry, repository, digests.signature, token, "")
651		if err != nil {
652			binaryResp.Body.Close()
653			return nil, nil, nil, err
654		}
655	}
656
657	if cfg.CreateLicenses && digests.license != "" {
658		licenseResp, err = downloadOCIBlob(ctx, registry, repository, digests.license, token, "")
659		if err != nil {
660			closeResponses(binaryResp, sigResp)
661			return nil, nil, nil, err
662		}
663	}
664
665	return binaryResp, sigResp, licenseResp, nil
666}
667
668type layerDigests struct {
669	binary, signature, license string
670}
671
672func findLayerDigests(layers []interface{}, title, titleNoExt string) layerDigests {
673	var digests layerDigests
674
675	for _, layer := range layers {
676		layerMap, ok := layer.(map[string]interface{})
677		if !ok {
678			continue
679		}
680		annotations, ok := layerMap["annotations"].(map[string]interface{})
681		if !ok {
682			continue
683		}
684		layerTitle, ok := annotations["org.opencontainers.image.title"].(string)
685		if !ok {
686			continue
687		}
688
689		digest := layerMap["digest"].(string)
690		switch layerTitle {
691		case title, titleNoExt:
692			digests.binary = digest
693		case title+".sig", titleNoExt+".sig":
694			digests.signature = digest
695		case "LICENSE", titleNoExt+".LICENSE":
696			digests.license = digest
697		}
698	}
699
700	return digests
701}
702
703func downloadOCIBlob(ctx context.Context, registry, repository, digest, token, tmpPath string) (*http.Response, error) {
704	url := fmt.Sprintf("https://%s/v2/%s/blobs/%s", registry, repository, digest)
705	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
706	if err != nil {
707		return nil, errOCILayerDownload.Wrap(err)
708	}
709	req.Header.Set("Authorization", "Bearer "+token)
710
711	if tmpPath != "" {
712		if meta, err := getOCIMeta(tmpPath); err == nil && meta.Offset > 0 {
713			req.Header.Set("Range", fmt.Sprintf("bytes=%d-", meta.Offset))
714		}
715	}
716
717	resp, err := http.DefaultClient.Do(req)
718	if err != nil {
719		return nil, errOCILayerDownload.Wrap(err)
720	}
721
722	return resp, nil
723}
724
725func cleanInstallCache(cfg *config) error {
726	targets := []struct {
727		dir       string
728		threshold time.Duration
729	}{
730		{cfg.InstallDir, 24 * time.Hour},
731		{cfg.LicenseDir, 10 * time.Second},
732	}
733
734	now := time.Now()
735	for _, target := range targets {
736		if err := cleanDirectory(target.dir, target.threshold, now); err != nil {
737			if verbosityLevel >= silentVerbosityWithErrors {
738				fmt.Fprintf(os.Stderr, "Error cleaning directory %s: %v\n", target.dir, err)
739			}
740		}
741	}
742
743	return nil
744}
745
746func cleanDirectory(dir string, threshold time.Duration, now time.Time) error {
747	if _, err := os.Stat(dir); os.IsNotExist(err) {
748		return nil
749	}
750
751	entries, err := os.ReadDir(dir)
752	if err != nil {
753		return err
754	}
755
756	for _, entry := range entries {
757		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tmp") {
758			continue
759		}
760
761		filePath := filepath.Join(dir, entry.Name())
762		if err := removeOldTempFile(filePath, threshold, now); err != nil {
763			if verbosityLevel >= silentVerbosityWithErrors {
764				fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", filePath, err)
765			}
766		}
767	}
768
769	return nil
770}
771
772func removeOldTempFile(filePath string, threshold time.Duration, now time.Time) error {
773	info, err := os.Stat(filePath)
774	if err != nil {
775		return err
776	}
777
778	stat, ok := info.Sys().(*syscall.Stat_t)
779	if !ok {
780		if verbosityLevel >= extraVerbose {
781			fmt.Fprintf(os.Stderr, "No ATime for %s, skipping\n", filePath)
782		}
783		return nil
784	}
785
786	if now.Sub(ATime(stat)) > threshold {
787		if err := os.Remove(filePath); err != nil {
788			return err
789		}
790		if verbosityLevel >= extraVerbose {
791			fmt.Printf("Removed .tmp file: %s\n", filePath)
792		}
793	}
794
795	return nil
796}
797
798
799func httpCheckSignatureAndLicense(ctx context.Context, client *http.Client, url string) (hasSignature, hasLicense bool, err error) {
800	// Check for signature file (.sig)
801	sigReq, err := createHTTPRequest(ctx, "HEAD", url+".sig")
802	if err != nil {
803		// Only return error if we can't create the request
804		return false, false, errDownloadFailed.Wrap(err)
805	}
806
807	sigResp, err := client.Do(sigReq)
808	if err != nil {
809		// HTTP request failed - treat as no signature file available
810		hasSignature = false
811	} else {
812		sigResp.Body.Close()
813		hasSignature = sigResp.StatusCode == http.StatusOK
814	}
815
816	// Check for license file (.LICENSE)
817	licenseReq, err := createHTTPRequest(ctx, "HEAD", url+".LICENSE")
818	if err != nil {
819		// Only return error if we can't create the request
820		return hasSignature, false, errDownloadFailed.Wrap(err)
821	}
822
823	licenseResp, err := client.Do(licenseReq)
824	if err != nil {
825		// HTTP request failed - treat as no license file available
826		hasLicense = false
827	} else {
828		licenseResp.Body.Close()
829		hasLicense = licenseResp.StatusCode == http.StatusOK
830	}
831
832	return hasSignature, hasLicense, nil
833}