package filesig import ( "bufio" "bytes" "errors" "fmt" "io" "strings" "golang.org/x/exp/slices" "github.com/safing/jess/lhash" ) // Text file metadata keys. const ( TextKeyPrefix = "jess-" TextChecksumKey = TextKeyPrefix + "checksum" TextSignatureKey = TextKeyPrefix + "signature" ) // Text Operation Errors. var ( ErrChecksumMissing = errors.New("no checksum found") ErrChecksumFailed = errors.New("checksum does not match") ErrSignatureMissing = errors.New("signature not found") ErrSignatureFailed = errors.New("signature does not match") ) // TextPlacement signifies where jess metadata is put in text files. type TextPlacement string const ( // TextPlacementTop places the metadata at end of file. TextPlacementTop TextPlacement = "top" // TextPlacementBottom places the metadata at end of file. TextPlacementBottom TextPlacement = "bottom" // TextPlacementAfterComment places the metadata at end of the top comment // block, or at the top, if the first line is not a comment. TextPlacementAfterComment TextPlacement = "after-comment" defaultMetaPlacement = TextPlacementAfterComment ) // AddTextFileChecksum adds a checksum to a text file. func AddTextFileChecksum(data []byte, commentSign string, placement TextPlacement) ([]byte, error) { // Split text file into content and jess metadata lines. content, metaLines, err := textSplit(data, commentSign) if err != nil { return nil, err } // Calculate checksum. h := lhash.BLAKE2b_256.Digest(content) metaLines = append(metaLines, TextChecksumKey+": "+h.Base58()) // Sort and deduplicate meta lines. slices.Sort[[]string, string](metaLines) metaLines = slices.Compact[[]string, string](metaLines) // Add meta lines and return. return textAddMeta(content, metaLines, commentSign, placement) } // VerifyTextFileChecksum checks a checksum in a text file. func VerifyTextFileChecksum(data []byte, commentSign string) error { // Split text file into content and jess metadata lines. content, metaLines, err := textSplit(data, commentSign) if err != nil { return err } // Verify all checksums. var checksumsVerified int for _, line := range metaLines { if strings.HasPrefix(line, TextChecksumKey) { // Clean key, delimiters and space. line = strings.TrimPrefix(line, TextChecksumKey) line = strings.TrimSpace(line) // Spaces and newlines. line = strings.Trim(line, ":= ") // Delimiters and spaces. // Parse checksum. h, err := lhash.FromBase58(line) if err != nil { return fmt.Errorf("%w: failed to parse labeled hash: %w", ErrChecksumFailed, err) } // Verify checksum. if !h.Matches(content) { return ErrChecksumFailed } checksumsVerified++ } } // Fail when no checksums were verified. if checksumsVerified == 0 { return ErrChecksumMissing } return nil } func textSplit(data []byte, commentSign string) (content []byte, metaLines []string, err error) { metaLinePrefix := commentSign + " " + TextKeyPrefix contentBuf := bytes.NewBuffer(make([]byte, 0, len(data))) metaLines = make([]string, 0, 1) // Find jess metadata lines. s := bufio.NewScanner(bytes.NewReader(data)) s.Split(scanRawLines) for s.Scan() { if strings.HasPrefix(s.Text(), metaLinePrefix) { metaLines = append(metaLines, strings.TrimSpace(strings.TrimPrefix(s.Text(), commentSign))) } else { _, _ = contentBuf.Write(s.Bytes()) } } if s.Err() != nil { return nil, nil, s.Err() } return bytes.TrimSpace(contentBuf.Bytes()), metaLines, nil } func detectLineEndFormat(data []byte) (lineEnd string) { i := bytes.IndexByte(data, '\n') switch i { case -1: // Default to just newline. return "\n" case 0: // File start with a newline. return "\n" default: // First newline is at second byte or later. if bytes.Equal(data[i-1:i+1], []byte("\r\n")) { return "\r\n" } return "\n" } } func textAddMeta(data []byte, metaLines []string, commentSign string, position TextPlacement) ([]byte, error) { // Prepare new buffer. requiredSize := len(data) for _, line := range metaLines { requiredSize += len(line) + len(commentSign) + 3 // space + CRLF } contentBuf := bytes.NewBuffer(make([]byte, 0, requiredSize)) // Find line ending. lineEnd := detectLineEndFormat(data) // Find jess metadata lines. if position == "" { position = defaultMetaPlacement } switch position { case TextPlacementTop: textWriteMetaLines(metaLines, commentSign, lineEnd, contentBuf) contentBuf.Write(data) // Add final newline. contentBuf.WriteString(lineEnd) case TextPlacementBottom: contentBuf.Write(data) // Add to newlines when appending, as content is first whitespace-stripped. contentBuf.WriteString(lineEnd) contentBuf.WriteString(lineEnd) textWriteMetaLines(metaLines, commentSign, lineEnd, contentBuf) case TextPlacementAfterComment: metaWritten := false s := bufio.NewScanner(bytes.NewReader(data)) s.Split(scanRawLines) for s.Scan() { switch { case metaWritten: _, _ = contentBuf.Write(s.Bytes()) case strings.HasPrefix(s.Text(), commentSign): _, _ = contentBuf.Write(s.Bytes()) default: textWriteMetaLines(metaLines, commentSign, lineEnd, contentBuf) metaWritten = true _, _ = contentBuf.Write(s.Bytes()) } } if s.Err() != nil { return nil, s.Err() } // If we have scanned through the file, and meta was not written, write it now. if !metaWritten { textWriteMetaLines(metaLines, commentSign, lineEnd, contentBuf) } // Add final newline. contentBuf.WriteString(lineEnd) } return contentBuf.Bytes(), nil } func textWriteMetaLines(metaLines []string, commentSign string, lineEnd string, writer io.StringWriter) { for _, line := range metaLines { _, _ = writer.WriteString(commentSign) _, _ = writer.WriteString(" ") _, _ = writer.WriteString(line) _, _ = writer.WriteString(lineEnd) } } // scanRawLines is a split function for a Scanner that returns each line of // text, including any trailing end-of-line marker. The returned line may // be empty. The end-of-line marker is one optional carriage return followed // by one mandatory newline. In regular expression notation, it is `\r?\n`. // The last non-empty line of input will be returned even if it has no // newline. func scanRawLines(data []byte, atEOF bool) (advance int, token []byte, err error) { if atEOF && len(data) == 0 { return 0, nil, nil } if i := bytes.IndexByte(data, '\n'); i >= 0 { // We have a full newline-terminated line. return i + 1, data[0 : i+1], nil } // If we're at EOF, we have a final, non-terminated line. Return it. if atEOF { return len(data), data, nil } // Request more data. return 0, nil, nil }