// SPDX-FileCopyrightText: 2020 Luke Granger-Brown // // SPDX-License-Identifier: Apache-2.0 // Package narinfo implements a simple narinfo read/writer. package narinfo import ( "bufio" "crypto/md5" "crypto/sha1" "crypto/sha256" "crypto/sha512" "encoding/base64" "encoding/hex" "fmt" "hash" "io" "regexp" "sort" "strconv" "strings" "github.com/numtide/go-nix/nixbase32" ) // CompressionMethod lists the supported compression methods. type CompressionMethod int const ( CompressionUnknown CompressionMethod = iota CompressionNone CompressionXz CompressionBzip2 CompressionGzip CompressionBrotli ) func (c CompressionMethod) String() string { switch c { case CompressionNone: return "none" case CompressionXz: return "xz" case CompressionBzip2: return "bzip2" case CompressionGzip: return "gzip" case CompressionBrotli: return "br" } return "!!unknown!!" } func compressionFromString(s string) CompressionMethod { switch s { case "none", "": return CompressionNone case "xz": return CompressionXz case "bzip2": return CompressionBzip2 case "gzip": return CompressionGzip case "br": return CompressionBrotli default: return CompressionUnknown } } // HashAlgorithm lists the supported hashing algorithms. type HashAlgorithm int const ( HashUnknown HashAlgorithm = iota HashMd5 HashSha1 HashSha256 HashSha512 ) func (a HashAlgorithm) String() string { switch a { case HashMd5: return "md5" case HashSha1: return "sha1" case HashSha256: return "sha256" case HashSha512: return "sha512" } return "!!unknown!!" } func (a HashAlgorithm) Hash() hash.Hash { switch a { case HashMd5: return md5.New() case HashSha1: return sha1.New() case HashSha256: return sha256.New() case HashSha512: return sha512.New() } panic("unknown hash algorithm") } func (a HashAlgorithm) sizes() sizes { sz := a.Hash().Size() return sizes{ rawLen: sz, base16Len: hex.EncodedLen(sz), base32Len: nixbase32.EncodedLen(sz), base64Len: base64.StdEncoding.EncodedLen(sz), } } type sizes struct { rawLen int base16Len int base32Len int base64Len int } // Hash represents a hash and its algorithm. type Hash struct { Algorithm HashAlgorithm Hash []byte IsSRI bool } // Valid returns if this Hash contains a valid hash. func (h Hash) Valid() bool { return h.Algorithm != HashUnknown && len(h.Hash) == h.Algorithm.sizes().rawLen } func (h Hash) String() string { if h.IsSRI { return fmt.Sprintf("%s-%s", h.Algorithm, base64.StdEncoding.EncodeToString(h.Hash)) } return fmt.Sprintf("%s:%s", h.Algorithm, nixbase32.EncodeToString(h.Hash)) } func HashFromString(s string) (Hash, error) { var h Hash idx := strings.IndexAny(s, "-:") if idx == -1 { return h, fmt.Errorf("no hashtype separator") } h.IsSRI = s[idx] == '-' hashtype, hashdata := s[:idx], s[idx+1:] switch hashtype { case "md5": h.Algorithm = HashMd5 case "sha1": h.Algorithm = HashSha1 case "sha256": h.Algorithm = HashSha256 case "sha512": h.Algorithm = HashSha512 default: return h, fmt.Errorf("unknown hash type %v", hashtype) } var hashbytes []byte sz := h.Algorithm.sizes() // We support the same things as Nix, which is base16 (hex), base32 (why), and base64 hashes. // For SRI, base64 is required. switch { case !h.IsSRI && len(hashdata) == sz.base16Len: var err error hashbytes, err = hex.DecodeString(hashdata) if err != nil { return h, fmt.Errorf("decoding hash %v as hex", hashdata) } case !h.IsSRI && len(hashdata) == sz.base32Len: var err error hashbytes, err = nixbase32.DecodeString(hashdata) if err != nil { return h, fmt.Errorf("decoding hash %v as nix-base32", hashdata) } case len(hashdata) == sz.base64Len: var err error hashbytes, err = base64.StdEncoding.DecodeString(hashdata) if err != nil { return h, fmt.Errorf("decoding hash %v as base64", hashdata) } default: return h, fmt.Errorf("unknown hash type for length %d", len(hashdata)) } h.Hash = hashbytes return h, nil } // NarInfo represents the content of a .narinfo file. type NarInfo struct { StorePath string URL string Compression CompressionMethod FileHash Hash FileSize uint64 NarHash Hash NarSize uint64 References []string Deriver string System string Sig map[string][]byte CA string } func (ni NarInfo) WriteTo(w io.Writer) error { if ni.StorePath != "" { if _, err := fmt.Fprintf(w, "StorePath: %s\n", ni.StorePath); err != nil { return err } } if ni.URL != "" { if _, err := fmt.Fprintf(w, "URL: %s\n", ni.URL); err != nil { return err } } if ni.Compression != CompressionUnknown { if _, err := fmt.Fprintf(w, "Compression: %s\n", ni.Compression); err != nil { return err } } if ni.FileHash.Valid() { if _, err := fmt.Fprintf(w, "FileHash: %s\n", ni.FileHash); err != nil { return err } } if ni.FileSize != 0 { if _, err := fmt.Fprintf(w, "FileSize: %d\n", ni.FileSize); err != nil { return err } } if ni.NarHash.Valid() { if _, err := fmt.Fprintf(w, "NarHash: %s\n", ni.NarHash); err != nil { return err } } if ni.NarSize != 0 { if _, err := fmt.Fprintf(w, "NarSize: %d\n", ni.NarSize); err != nil { return err } } if len(ni.References) != 0 { if _, err := fmt.Fprintf(w, "References: %s\n", strings.Join(ni.References, " ")); err != nil { return err } } if ni.Deriver != "" { if _, err := fmt.Fprintf(w, "Deriver: %s\n", ni.Deriver); err != nil { return err } } if ni.System != "" { if _, err := fmt.Fprintf(w, "System: %s\n", ni.System); err != nil { return err } } if len(ni.Sig) != 0 { sigKeys := make([]string, 0, len(ni.Sig)) for k := range ni.Sig { sigKeys = append(sigKeys, k) } sort.Strings(sigKeys) for _, k := range sigKeys { v := ni.Sig[k] if _, err := fmt.Fprintf(w, "Sig: %s:%s\n", k, base64.StdEncoding.EncodeToString(v)); err != nil { return err } } } if ni.CA != "" { if _, err := fmt.Fprintf(w, "CA: %s\n", ni.CA); err != nil { return err } } return nil } func (ni NarInfo) String() string { var b strings.Builder ni.WriteTo(&b) return b.String() } var narLineRegexp = regexp.MustCompile(`^([A-Za-z]+): (.*)$`) // ParseNarInfo parses NarInfo data from a Reader. func ParseNarInfo(r io.Reader) (*NarInfo, error) { ni := NarInfo{ Compression: CompressionBzip2, } s := bufio.NewScanner(r) for s.Scan() { submatches := narLineRegexp.FindStringSubmatch(s.Text()) switch { case len(submatches) == 0: continue case len(submatches) != 3: return nil, fmt.Errorf("failed to parse line %q: wrong number of submatches", s.Text()) } name, value := submatches[1], submatches[2] switch name { case "StorePath": ni.StorePath = value case "URL": ni.URL = value case "Compression": ni.Compression = compressionFromString(value) if ni.Compression == CompressionUnknown { return nil, fmt.Errorf("unknown compression method %q", value) } case "FileHash": h, err := HashFromString(value) if err != nil { return nil, fmt.Errorf("parsing %q as FileHash: %w", value, err) } ni.FileHash = h case "FileSize": var err error ni.FileSize, err = strconv.ParseUint(value, 10, 64) if err != nil { return nil, fmt.Errorf("parsing %q as FileSize: %w", value, err) } case "NarHash": h, err := HashFromString(value) if err != nil { return nil, fmt.Errorf("parsing %q as NarHash: %w", value, err) } ni.NarHash = h case "NarSize": var err error ni.NarSize, err = strconv.ParseUint(value, 10, 64) if err != nil { return nil, fmt.Errorf("parsing %q as NarSize: %w", value, err) } case "References": ni.References = strings.Split(value, " ") if len(ni.References) == 1 && ni.References[0] == "" { ni.References = nil } case "Deriver": if value != "unknown-deriver" { ni.Deriver = value } case "System": ni.System = value case "Sig": if ni.Sig == nil { ni.Sig = make(map[string][]byte) } bits := strings.SplitN(value, ":", 2) if len(bits) != 2 { return nil, fmt.Errorf("parsing %q as Sig: not enough pieces", value) } rawVal, err := base64.StdEncoding.DecodeString(bits[1]) if err != nil { return nil, fmt.Errorf("parsing %q as Sig: %w", value, err) } ni.Sig[bits[0]] = rawVal case "CA": ni.CA = value } } if err := s.Err(); err != nil { return nil, err } switch { case ni.StorePath == "": return nil, fmt.Errorf("StorePath missing") case !ni.NarHash.Valid(): return nil, fmt.Errorf("NarHash missing or invalid") case ni.URL == "": return nil, fmt.Errorf("URL empty") case ni.NarSize == 0: return nil, fmt.Errorf("NAR is 0-size") } return &ni, nil }