232 lines
6.5 KiB
Go
232 lines
6.5 KiB
Go
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
|
|
}
|