diff --git a/loglist3/logfilter.go b/loglist3/logfilter.go new file mode 100644 index 0000000000..7c14116528 --- /dev/null +++ b/loglist3/logfilter.go @@ -0,0 +1,125 @@ +// Copyright 2022 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loglist3 + +import ( + "github.com/golang/glog" + "github.com/google/certificate-transparency-go/trillian/ctfe" + "github.com/google/certificate-transparency-go/x509" +) + +// LogRoots maps Log-URLs (stated at LogList) to the pools of their accepted +// root-certificates. +type LogRoots map[string]*ctfe.PEMCertPool + +// Compatible creates a new LogList containing only Logs matching the temporal, +// root-acceptance and Log-status conditions. +func (ll *LogList) Compatible(cert *x509.Certificate, certRoot *x509.Certificate, roots LogRoots) LogList { + active := ll.TemporallyCompatible(cert) + return active.RootCompatible(certRoot, roots) +} + +// SelectByStatus creates a new LogList containing only logs with status +// provided from the original. +func (ll *LogList) SelectByStatus(lstats []LogStatus) LogList { + var active LogList + for _, op := range ll.Operators { + activeOp := *op + activeOp.Logs = []*Log{} + for _, l := range op.Logs { + for _, lstat := range lstats { + if l.State.LogStatus() == lstat { + activeOp.Logs = append(activeOp.Logs, l) + break + } + } + } + if len(activeOp.Logs) > 0 { + active.Operators = append(active.Operators, &activeOp) + } + } + return active +} + +// RootCompatible creates a new LogList containing only the logs of original +// LogList that are compatible with the provided cert, according to +// the passed in collection of per-log roots. Logs that are missing from +// the collection are treated as always compatible and included, even if +// an empty cert root is passed in. +// Cert-root when provided is expected to be CA-cert. +func (ll *LogList) RootCompatible(certRoot *x509.Certificate, roots LogRoots) LogList { + var compatible LogList + + // Check whether root is a CA-cert. + if certRoot != nil && !certRoot.IsCA { + glog.Warningf("Compatible method expects fully rooted chain, while last cert of the chain provided is not root") + return compatible + } + + for _, op := range ll.Operators { + compatibleOp := *op + compatibleOp.Logs = []*Log{} + for _, l := range op.Logs { + // If root set is not defined, we treat Log as compatible assuming no + // knowledge of its roots. + if _, ok := roots[l.URL]; !ok { + compatibleOp.Logs = append(compatibleOp.Logs, l) + continue + } + + if certRoot == nil { + continue + } + + // Check root is accepted. + if roots[l.URL].Included(certRoot) { + compatibleOp.Logs = append(compatibleOp.Logs, l) + } + } + if len(compatibleOp.Logs) > 0 { + compatible.Operators = append(compatible.Operators, &compatibleOp) + } + } + return compatible +} + +// TemporallyCompatible creates a new LogList containing only the logs of +// original LogList that are compatible with the provided cert, according to +// NotAfter and TemporalInterval matching. +// Returns empty LogList if nil-cert is provided. +func (ll *LogList) TemporallyCompatible(cert *x509.Certificate) LogList { + var compatible LogList + if cert == nil { + return compatible + } + + for _, op := range ll.Operators { + compatibleOp := *op + compatibleOp.Logs = []*Log{} + for _, l := range op.Logs { + if l.TemporalInterval == nil { + compatibleOp.Logs = append(compatibleOp.Logs, l) + continue + } + if cert.NotAfter.Before(l.TemporalInterval.EndExclusive) && (cert.NotAfter.After(l.TemporalInterval.StartInclusive) || cert.NotAfter.Equal(l.TemporalInterval.StartInclusive)) { + compatibleOp.Logs = append(compatibleOp.Logs, l) + } + } + if len(compatibleOp.Logs) > 0 { + compatible.Operators = append(compatible.Operators, &compatibleOp) + } + } + return compatible +} diff --git a/loglist3/logfilter_test.go b/loglist3/logfilter_test.go new file mode 100644 index 0000000000..0eeb527b3a --- /dev/null +++ b/loglist3/logfilter_test.go @@ -0,0 +1,295 @@ +// Copyright 2022 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package loglist3 + +import ( + "log" + "testing" + "time" + + "github.com/google/certificate-transparency-go/testdata" + "github.com/google/certificate-transparency-go/trillian/ctfe" + "github.com/google/certificate-transparency-go/x509" + "github.com/google/certificate-transparency-go/x509util" + + "github.com/kylelemons/godebug/pretty" +) + +func subLogList(logURLs map[string]bool) LogList { + var ll LogList + for _, op := range sampleLogList.Operators { + opCopy := *op + opCopy.Logs = []*Log{} + for _, l := range op.Logs { + if logURLs[l.URL] { + opCopy.Logs = append(opCopy.Logs, l) + } + } + if len(opCopy.Logs) > 0 { + ll.Operators = append(ll.Operators, &opCopy) + } + } + return ll +} + +func TestSelectUsable(t *testing.T) { + tests := []struct { + name string + in LogList + want LogList + }{ + { + name: "Sample", + in: sampleLogList, + want: subLogList(map[string]bool{"https://ct.googleapis.com/icarus/": true}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + usableStat := make([]LogStatus, 1) + usableStat[0] = UsableLogStatus + got := test.in.SelectByStatus(usableStat) + if diff := pretty.Compare(test.want, got); diff != "" { + t.Errorf("Extracting active logs out of %v diff: (-want +got)\n%s", test.in, diff) + } + }) + } +} + +func TestSelectPendingAndQualified(t *testing.T) { + tests := []struct { + name string + in LogList + want LogList + }{ + { + name: "Sample", + in: sampleLogList, + want: subLogList(map[string]bool{"https://ct.googleapis.com/logs/argon2020/": true}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stats := make([]LogStatus, 2) + stats[0] = PendingLogStatus + stats[1] = QualifiedLogStatus + got := test.in.SelectByStatus(stats) + if diff := pretty.Compare(test.want, got); diff != "" { + t.Errorf("Extracting qualified logs out of %v diff: (-want +got)\n%s", test.in, diff) + } + }) + } +} + +func artificialRoots(source string) LogRoots { + roots := LogRoots{ + "https://log.bob.io": ctfe.NewPEMCertPool(), + "https://ct.googleapis.com/racketeer/": ctfe.NewPEMCertPool(), + "https://ct.googleapis.com/rocketeer/": ctfe.NewPEMCertPool(), + "https://ct.googleapis.com/aviator/": ctfe.NewPEMCertPool(), + "https://ct.googleapis.com/logs/argon2020/": ctfe.NewPEMCertPool(), + } + roots["https://log.bob.io"].AppendCertsFromPEM([]byte(source)) + return roots +} + +func TestRootCompatible(t *testing.T) { + cert, _ := x509util.CertificateFromPEM([]byte(testdata.TestPreCertPEM)) + caCert, _ := x509util.CertificateFromPEM([]byte(testdata.CACertPEM)) + + tests := []struct { + name string + in LogList + cert *x509.Certificate + rootCert *x509.Certificate + roots LogRoots + want LogList + }{ + { + name: "RootedChain", + in: sampleLogList, + cert: cert, + rootCert: caCert, + roots: artificialRoots(testdata.CACertPEM), + want: subLogList(map[string]bool{"https://log.bob.io": true, "https://ct.googleapis.com/icarus/": true}), // icarus has no root info. + }, + { + name: "RootedChainNoRootAccepted", + in: sampleLogList, + cert: cert, + rootCert: caCert, + roots: artificialRoots(testdata.TestPreCertPEM), + want: subLogList(map[string]bool{"https://ct.googleapis.com/icarus/": true}), // icarus has no root info. + }, + { + name: "Non-RootedChain", + in: sampleLogList, + cert: cert, + rootCert: cert, + roots: artificialRoots(testdata.CACertPEM), + want: subLogList(map[string]bool{}), + }, + { + name: "EmptyChain", + in: sampleLogList, + cert: nil, + rootCert: nil, + roots: artificialRoots(testdata.CACertPEM), + want: subLogList(map[string]bool{"https://ct.googleapis.com/icarus/": true}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.in.RootCompatible(test.rootCert, test.roots) + if diff := pretty.Compare(test.want, got); diff != "" { + t.Errorf("Getting compatible logs diff: (-want +got)\n%s", diff) + } + }) + } +} + +// stripErr is helper func for wrapping time.Parse +func stripErr(t time.Time, err error) time.Time { + if err != nil { + log.Fatal(err) + } + return t +} + +func TestTemporallyCompatible(t *testing.T) { + cert, _ := x509util.CertificateFromPEM([]byte(testdata.TestPreCertPEM)) + + tests := []struct { + name string + in LogList + cert *x509.Certificate + notAfter time.Time + want LogList + }{ + { + name: "AllLogsFitTemporally", + in: sampleLogList, + cert: cert, + notAfter: stripErr(time.Parse(time.UnixDate, "Sat Nov 8 11:06:00 PST 2014")), + want: subLogList(map[string]bool{"https://ct.googleapis.com/aviator/": true, "https://log.bob.io": true, "https://ct.googleapis.com/icarus/": true, "https://ct.googleapis.com/racketeer/": true, "https://ct.googleapis.com/rocketeer/": true}), + }, + { + name: "OperatorExcludedAllItsLogsMismatch", + in: sampleLogList, + cert: cert, + notAfter: stripErr(time.Parse(time.UnixDate, "Sat Mar 8 11:06:00 PST 2014")), + want: subLogList(map[string]bool{"https://ct.googleapis.com/aviator/": true, "https://ct.googleapis.com/icarus/": true, "https://ct.googleapis.com/racketeer/": true, "https://ct.googleapis.com/rocketeer/": true}), + }, + { + name: "TwoLogsAfterCertTimeExcluded", + in: sampleLogList, + cert: cert, + notAfter: stripErr(time.Parse(time.UnixDate, "Sat Mar 8 11:06:00 PST 2013")), + want: subLogList(map[string]bool{"https://ct.googleapis.com/icarus/": true, "https://ct.googleapis.com/racketeer/": true, "https://ct.googleapis.com/rocketeer/": true}), + }, + { + name: "TwoLogsBeforeCertTimeExcluded", + in: sampleLogList, + cert: cert, + notAfter: stripErr(time.Parse(time.UnixDate, "Sat Mar 8 11:06:00 PST 2016")), + want: subLogList(map[string]bool{"https://ct.googleapis.com/icarus/": true, "https://ct.googleapis.com/racketeer/": true, "https://ct.googleapis.com/rocketeer/": true}), + }, + { + name: "NilCert", + in: sampleLogList, + cert: nil, + notAfter: stripErr(time.Parse(time.UnixDate, "Sat Nov 8 11:06:00 PST 2014")), + want: subLogList(map[string]bool{}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.cert != nil { + test.cert.NotAfter = test.notAfter + } + got := test.in.TemporallyCompatible(test.cert) + if diff := pretty.Compare(test.want, got); diff != "" { + t.Errorf("Getting NotBefore-compatible logs diff: (-want +got)\n%s", diff) + } + }) + } +} + +func TestCompatible(t *testing.T) { + cert, _ := x509util.CertificateFromPEM([]byte(testdata.TestPreCertPEM)) + caCert, _ := x509util.CertificateFromPEM([]byte(testdata.CACertPEM)) + + tests := []struct { + name string + in LogList + notBefore time.Time + cert *x509.Certificate + rootCert *x509.Certificate + roots LogRoots + want LogList + }{ + { + name: "RootedChain", + in: sampleLogList, + notBefore: stripErr(time.Parse(time.UnixDate, "Sat Mar 8 11:06:00 PST 2016")), + cert: cert, + rootCert: caCert, + roots: artificialRoots(testdata.CACertPEM), + want: subLogList(map[string]bool{"https://ct.googleapis.com/icarus/": true}), // icarus has no root info. + }, + { + name: "RootedChainNoRootAccepted", + in: sampleLogList, + notBefore: stripErr(time.Parse(time.UnixDate, "Sat Mar 8 11:06:00 PST 2016")), + cert: cert, + rootCert: caCert, + roots: artificialRoots(testdata.TestPreCertPEM), + want: subLogList(map[string]bool{"https://ct.googleapis.com/icarus/": true}), // icarus has no root info. + }, + { + name: "Non-RootedChain", + in: sampleLogList, + notBefore: stripErr(time.Parse(time.UnixDate, "Sat Mar 8 11:06:00 PST 2016")), + cert: cert, + rootCert: cert, + roots: artificialRoots(testdata.CACertPEM), + want: subLogList(map[string]bool{}), + }, + { + name: "EmptyChain", + in: sampleLogList, + notBefore: stripErr(time.Parse(time.UnixDate, "Sat Mar 8 11:06:00 PST 2016")), + cert: nil, + rootCert: nil, + roots: artificialRoots(testdata.CACertPEM), + want: subLogList(map[string]bool{}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.cert != nil { + test.cert.NotBefore = test.notBefore + } + got := test.in.Compatible(test.cert, test.rootCert, test.roots) + if diff := pretty.Compare(test.want, got); diff != "" { + t.Errorf("Getting compatible logs diff: (-want +got)\n%s", diff) + } + }) + } +} diff --git a/loglist3/loglist3.go b/loglist3/loglist3.go new file mode 100644 index 0000000000..6c3b23d8be --- /dev/null +++ b/loglist3/loglist3.go @@ -0,0 +1,388 @@ +// Copyright 2022 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package loglist3 allows parsing and searching of the master CT Log list. +// It expects the log list to conform to the v3 schema. +package loglist3 + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + "unicode" + + "github.com/google/certificate-transparency-go/tls" +) + +const ( + // LogListURL has the master URL for Google Chrome's log list. + LogListURL = "https://www.gstatic.com/ct/log_list/v3/log_list.json" + // LogListSignatureURL has the URL for the signature over Google Chrome's log list. + LogListSignatureURL = "https://www.gstatic.com/ct/log_list/v3/log_list.sig" + // AllLogListURL has the URL for the list of all known logs (which isn't signed). + AllLogListURL = "https://www.gstatic.com/ct/log_list/v3/all_logs_list.json" +) + +// Manually mapped from https://www.gstatic.com/ct/log_list/v3/log_list_schema.json + +// LogList holds a collection of CT logs, grouped by operator. +type LogList struct { + // Version is the version of the log list. + Version string `json:"version,omitempty"` + // LogListTimestamp is the time at which the log list was published. + LogListTimestamp time.Time `json:"log_list_timestamp,omitempty"` + // Operators is a list of CT log operators and the logs they operate. + Operators []*Operator `json:"operators"` +} + +// Operator holds a collection of CT logs run by the same organisation. +// It also provides information about that organisation, e.g. contact details. +type Operator struct { + // Name is the name of the CT log operator. + Name string `json:"name"` + // Email lists the email addresses that can be used to contact this log + // operator. + Email []string `json:"email"` + // Logs is a list of CT logs run by this operator. + Logs []*Log `json:"logs"` +} + +// Log describes a single CT log. +type Log struct { + // Description is a human-readable string that describes the log. + Description string `json:"description,omitempty"` + // LogID is the SHA-256 hash of the log's public key. + LogID []byte `json:"log_id"` + // Key is the public key with which signatures can be verified. + Key []byte `json:"key"` + // URL is the address of the HTTPS API. + URL string `json:"url"` + // DNS is the address of the DNS API. + DNS string `json:"dns,omitempty"` + // MMD is the Maximum Merge Delay, in seconds. All submitted + // certificates must be incorporated into the log within this time. + MMD int32 `json:"mmd"` + // PreviousOperators is a list of previous operators and the timestamp + // of when they stopped running the log. + PreviousOperators []*PreviousOperator `json:"previous_operators,omitempty"` + // State is the current state of the log, from the perspective of the + // log list distributor. + State *LogStates `json:"state,omitempty"` + // TemporalInterval, if set, indicates that this log only accepts + // certificates with a NotAfter date in this time range. + TemporalInterval *TemporalInterval `json:"temporal_interval,omitempty"` + // Type indicates the purpose of this log, e.g. "test" or "prod". + Type string `json:"log_type,omitempty"` +} + +// PreviousOperator holds information about a log operator and the time at which +// they stopped running a log. +type PreviousOperator struct { + // Name is the name of the CT log operator. + Name string `json:"name"` + // EndTime is the time at which the operator stopped running a log. + EndTime time.Time `json:"end_time"` +} + +// TemporalInterval is a time range. +type TemporalInterval struct { + // StartInclusive is the beginning of the time range. + StartInclusive time.Time `json:"start_inclusive"` + // EndExclusive is just after the end of the time range. + EndExclusive time.Time `json:"end_exclusive"` +} + +// LogStatus indicates Log status. +type LogStatus int + +// LogStatus values +const ( + UndefinedLogStatus LogStatus = iota + PendingLogStatus + QualifiedLogStatus + UsableLogStatus + ReadOnlyLogStatus + RetiredLogStatus + RejectedLogStatus +) + +//go:generate stringer -type=LogStatus + +// LogStates are the states that a CT log can be in, from the perspective of a +// user agent. Only one should be set - this is the current state. +type LogStates struct { + // Pending indicates that the log is in the "pending" state. + Pending *LogState `json:"pending,omitempty"` + // Qualified indicates that the log is in the "qualified" state. + Qualified *LogState `json:"qualified,omitempty"` + // Usable indicates that the log is in the "usable" state. + Usable *LogState `json:"usable,omitempty"` + // ReadOnly indicates that the log is in the "readonly" state. + ReadOnly *ReadOnlyLogState `json:"readonly,omitempty"` + // Retired indicates that the log is in the "retired" state. + Retired *LogState `json:"retired,omitempty"` + // Rejected indicates that the log is in the "rejected" state. + Rejected *LogState `json:"rejected,omitempty"` +} + +// LogState contains details on the current state of a CT log. +type LogState struct { + // Timestamp is the time when the state began. + Timestamp time.Time `json:"timestamp"` +} + +// ReadOnlyLogState contains details on the current state of a read-only CT log. +type ReadOnlyLogState struct { + LogState + // FinalTreeHead is the root hash and tree size at which the CT log was + // made read-only. This should never change while the log is read-only. + FinalTreeHead TreeHead `json:"final_tree_head"` +} + +// TreeHead is the root hash and tree size of a CT log. +type TreeHead struct { + // SHA256RootHash is the root hash of the CT log's Merkle tree. + SHA256RootHash []byte `json:"sha256_root_hash"` + // TreeSize is the size of the CT log's Merkle tree. + TreeSize int64 `json:"tree_size"` +} + +// LogStatus method returns Log-status enum value for descriptive struct. +func (ls *LogStates) LogStatus() LogStatus { + switch { + case ls == nil: + return UndefinedLogStatus + case ls.Pending != nil: + return PendingLogStatus + case ls.Qualified != nil: + return QualifiedLogStatus + case ls.Usable != nil: + return UsableLogStatus + case ls.ReadOnly != nil: + return ReadOnlyLogStatus + case ls.Retired != nil: + return RetiredLogStatus + case ls.Rejected != nil: + return RejectedLogStatus + default: + return UndefinedLogStatus + } +} + +// String method returns printable name of the state. +func (ls *LogStates) String() string { + return ls.LogStatus().String() +} + +// Active picks the set-up state. If multiple states are set (not expected) picks one of them. +func (ls *LogStates) Active() (*LogState, *ReadOnlyLogState) { + if ls == nil { + return nil, nil + } + switch { + case ls.Pending != nil: + return ls.Pending, nil + case ls.Qualified != nil: + return ls.Qualified, nil + case ls.Usable != nil: + return ls.Usable, nil + case ls.ReadOnly != nil: + return nil, ls.ReadOnly + case ls.Retired != nil: + return ls.Retired, nil + case ls.Rejected != nil: + return ls.Rejected, nil + default: + return nil, nil + } +} + +// GoogleOperated returns whether Operator is considered to be Google. +func (op *Operator) GoogleOperated() bool { + for _, email := range op.Email { + if strings.Contains(email, "google-ct-logs@googlegroups") { + return true + } + } + return false +} + +// NewFromJSON creates a LogList from JSON encoded data. +func NewFromJSON(llData []byte) (*LogList, error) { + var ll LogList + if err := json.Unmarshal(llData, &ll); err != nil { + return nil, fmt.Errorf("failed to parse log list: %v", err) + } + return &ll, nil +} + +// NewFromSignedJSON creates a LogList from JSON encoded data, checking a +// signature along the way. The signature data should be provided as the +// raw signature data. +func NewFromSignedJSON(llData, rawSig []byte, pubKey crypto.PublicKey) (*LogList, error) { + var sigAlgo tls.SignatureAlgorithm + switch pkType := pubKey.(type) { + case *rsa.PublicKey: + sigAlgo = tls.RSA + case *ecdsa.PublicKey: + sigAlgo = tls.ECDSA + default: + return nil, fmt.Errorf("unsupported public key type %v", pkType) + } + tlsSig := tls.DigitallySigned{ + Algorithm: tls.SignatureAndHashAlgorithm{ + Hash: tls.SHA256, + Signature: sigAlgo, + }, + Signature: rawSig, + } + if err := tls.VerifySignature(pubKey, llData, tlsSig); err != nil { + return nil, fmt.Errorf("failed to verify signature: %v", err) + } + return NewFromJSON(llData) +} + +// FindLogByName returns all logs whose names contain the given string. +func (ll *LogList) FindLogByName(name string) []*Log { + name = strings.ToLower(name) + var results []*Log + for _, op := range ll.Operators { + for _, log := range op.Logs { + if strings.Contains(strings.ToLower(log.Description), name) { + results = append(results, log) + } + } + } + return results +} + +// FindLogByURL finds the log with the given URL. +func (ll *LogList) FindLogByURL(url string) *Log { + for _, op := range ll.Operators { + for _, log := range op.Logs { + // Don't count trailing slashes + if strings.TrimRight(log.URL, "/") == strings.TrimRight(url, "/") { + return log + } + } + } + return nil +} + +// FindLogByKeyHash finds the log with the given key hash. +func (ll *LogList) FindLogByKeyHash(keyhash [sha256.Size]byte) *Log { + for _, op := range ll.Operators { + for _, log := range op.Logs { + if bytes.Equal(log.LogID, keyhash[:]) { + return log + } + } + } + return nil +} + +// FindLogByKeyHashPrefix finds all logs whose key hash starts with the prefix. +func (ll *LogList) FindLogByKeyHashPrefix(prefix string) []*Log { + var results []*Log + for _, op := range ll.Operators { + for _, log := range op.Logs { + hh := hex.EncodeToString(log.LogID[:]) + if strings.HasPrefix(hh, prefix) { + results = append(results, log) + } + } + } + return results +} + +// FindLogByKey finds the log with the given DER-encoded key. +func (ll *LogList) FindLogByKey(key []byte) *Log { + for _, op := range ll.Operators { + for _, log := range op.Logs { + if bytes.Equal(log.Key[:], key) { + return log + } + } + } + return nil +} + +var hexDigits = regexp.MustCompile("^[0-9a-fA-F]+$") + +// FuzzyFindLog tries to find logs that match the given unspecified input, +// whose format is unspecified. This generally returns a single log, but +// if text input that matches multiple log descriptions is provided, then +// multiple logs may be returned. +func (ll *LogList) FuzzyFindLog(input string) []*Log { + input = strings.Trim(input, " \t") + if logs := ll.FindLogByName(input); len(logs) > 0 { + return logs + } + if log := ll.FindLogByURL(input); log != nil { + return []*Log{log} + } + // Try assuming the input is binary data of some form. First base64: + if data, err := base64.StdEncoding.DecodeString(input); err == nil { + if len(data) == sha256.Size { + var hash [sha256.Size]byte + copy(hash[:], data) + if log := ll.FindLogByKeyHash(hash); log != nil { + return []*Log{log} + } + } + if log := ll.FindLogByKey(data); log != nil { + return []*Log{log} + } + } + // Now hex, but strip all internal whitespace first. + input = stripInternalSpace(input) + if data, err := hex.DecodeString(input); err == nil { + if len(data) == sha256.Size { + var hash [sha256.Size]byte + copy(hash[:], data) + if log := ll.FindLogByKeyHash(hash); log != nil { + return []*Log{log} + } + } + if log := ll.FindLogByKey(data); log != nil { + return []*Log{log} + } + } + // Finally, allow hex strings with an odd number of digits. + if hexDigits.MatchString(input) { + if logs := ll.FindLogByKeyHashPrefix(input); len(logs) > 0 { + return logs + } + } + + return nil +} + +func stripInternalSpace(input string) string { + return strings.Map(func(r rune) rune { + if !unicode.IsSpace(r) { + return r + } + return -1 + }, input) +} diff --git a/loglist3/loglist3_test.go b/loglist3/loglist3_test.go new file mode 100644 index 0000000000..7e21447921 --- /dev/null +++ b/loglist3/loglist3_test.go @@ -0,0 +1,550 @@ +// Copyright 2022 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loglist3 + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/sergi/go-diff/diffmatchpatch" +) + +// mustParseTime is helper func +func mustParseTime(format string, sTime string) time.Time { + tm, e := time.Parse(format, sTime) + if e != nil { + log.Fatal(e) + } + return tm.UTC() +} + +var sampleLogList = LogList{ + Version: "1.1.1c", + LogListTimestamp: mustParseTime(time.UnixDate, "Fri Dec 3 11:06:00 UTC 2021"), + Operators: []*Operator{ + { + Name: "Google", + Email: []string{"google-ct-logs@googlegroups.com"}, + Logs: []*Log{ + { + Description: "Google 'Aviator' log", + LogID: deb64("aPaY+B9kgr46jO65KB1M/HFRXWeT1ETRCmesu09P+8Q="), + Key: deb64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1/TMabLkDpCjiupacAlP7xNi0I1JYP8bQFAHDG1xhtolSY1l4QgNRzRrvSe8liE+NPWHdjGxfx3JhTsN9x8/6Q=="), + URL: "https://ct.googleapis.com/aviator/", + MMD: 86400, + State: &LogStates{ + ReadOnly: &ReadOnlyLogState{ + LogState: LogState{Timestamp: time.Unix(1480512258, 330000000).UTC()}, + FinalTreeHead: TreeHead{ + TreeSize: 46466472, + SHA256RootHash: deb64("LcGcZRsm+LGYmrlyC5LXhV1T6OD8iH5dNlb0sEJl9bA="), + }, + }, + }, + TemporalInterval: &TemporalInterval{ + StartInclusive: mustParseTime(time.UnixDate, "Fri Mar 7 11:06:00 UTC 2014"), + EndExclusive: mustParseTime(time.UnixDate, "Sat Mar 7 12:00:00 UTC 2015"), + }, + DNS: "aviator.ct.googleapis.com", + }, + { + Description: "Google 'Icarus' log", + LogID: deb64("KTxRllTIOWW6qlD8WAfUt2+/WHopctykwwz05UVH9Hg="), + Key: deb64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAETtK8v7MICve56qTHHDhhBOuV4IlUaESxZryCfk9QbG9co/CqPvTsgPDbCpp6oFtyAHwlDhnvr7JijXRD9Cb2FA=="), + URL: "https://ct.googleapis.com/icarus/", + MMD: 86400, + State: &LogStates{ + Usable: &LogState{ + Timestamp: mustParseTime(time.RFC3339, "2018-02-27T00:00:00Z"), + }, + }, + DNS: "icarus.ct.googleapis.com", + }, + { + Description: "Google 'Racketeer' log", + LogID: deb64("7kEv4llINIlh4vPgjGgugT7A/3cLbXUXF2OvMBT/l2g="), + // Key value chosed to have a hash that starts ee4... (specifically ee412fe25948348961e2f3e08c682e813ec0ff770b6d75171763af3014ff9768) + Key: deb64("Hy2TPTZ2yq9ASMmMZiB9SZEUx5WNH5G0Ft5Tm9vKMcPXA+ic/Ap3gg6fXzBJR8zLkt5lQjvKMdbHYMGv7yrsZg=="), + URL: "https://ct.googleapis.com/racketeer/", + MMD: 86400, + DNS: "racketeer.ct.googleapis.com", + }, + { + Description: "Google 'Rocketeer' log", + LogID: deb64("7ku9t3XOYLrhQmkfq+GeZqMPfl+wctiDAMR7iXqo/cs="), + Key: deb64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIFsYyDzBi7MxCAC/oJBXK7dHjG+1aLCOkHjpoHPqTyghLpzA9BYbqvnV16mAw04vUjyYASVGJCUoI3ctBcJAeg=="), + URL: "https://ct.googleapis.com/rocketeer/", + MMD: 86400, + DNS: "rocketeer.ct.googleapis.com", + }, + { + Description: "Google 'Argon2020' log", + LogID: deb64("sh4FzIuizYogTodm+Su5iiUgZ2va+nDnsklTLe+LkF4="), + Key: deb64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6Tx2p1yKY4015NyIYvdrk36es0uAc1zA4PQ+TGRY+3ZjUTIYY9Wyu+3q/147JG4vNVKLtDWarZwVqGkg6lAYzA=="), + URL: "https://ct.googleapis.com/logs/argon2020/", + DNS: "argon2020.ct.googleapis.com", + MMD: 86400, + State: &LogStates{ + Qualified: &LogState{ + Timestamp: mustParseTime(time.RFC3339, "2018-02-27T00:00:00Z"), + }, + }, + TemporalInterval: &TemporalInterval{ + StartInclusive: mustParseTime(time.RFC3339, "2020-01-01T00:00:00Z"), + EndExclusive: mustParseTime(time.RFC3339, "2021-01-01T00:00:00Z"), + }, + }, + }, + }, + { + Name: "Bob's CT Log Shop", + Email: []string{"bob@example.com"}, + Logs: []*Log{ + { + Description: "Bob's Dubious Log", + LogID: deb64("zbUXm3/BwEb+6jETaj+PAC5hgvr4iW/syLL1tatgSQA="), + Key: deb64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECyPLhWKYYUgEc+tUXfPQB4wtGS2MNvXrjwFCCnyYJifBtd2Sk7Cu+Js9DNhMTh35FftHaHu6ZrclnNBKwmbbSA=="), + URL: "https://log.bob.io", + MMD: 86400, + State: &LogStates{ + Retired: &LogState{ + Timestamp: time.Unix(1460678400, 0).UTC(), + }, + }, + TemporalInterval: &TemporalInterval{ + StartInclusive: mustParseTime(time.UnixDate, "Fri Nov 7 12:00:00 UTC 2014"), + EndExclusive: mustParseTime(time.UnixDate, "Sat Mar 7 12:00:00 UTC 2015"), + }, + DNS: "dubious-bob.ct.googleapis.com", + PreviousOperators: []*PreviousOperator{ + { + Name: "Alice's Shady Log", + EndTime: mustParseTime(time.UnixDate, "Thu Nov 6 12:00:00 UTC 2014"), + }, + }, + }, + }, + }, + }, +} + +func TestJSONMarshal(t *testing.T) { + var tests = []struct { + name string + in LogList + want, wantErr string + }{ + { + name: "MultiValid", + in: sampleLogList, + want: `{"version":"1.1.1c","log_list_timestamp":"2021-12-03T11:06:00Z","operators":[` + + `{"name":"Google","email":["google-ct-logs@googlegroups.com"],"logs":[` + + `{"description":"Google 'Aviator' log","log_id":"aPaY+B9kgr46jO65KB1M/HFRXWeT1ETRCmesu09P+8Q=","key":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1/TMabLkDpCjiupacAlP7xNi0I1JYP8bQFAHDG1xhtolSY1l4QgNRzRrvSe8liE+NPWHdjGxfx3JhTsN9x8/6Q==","url":"https://ct.googleapis.com/aviator/","dns":"aviator.ct.googleapis.com","mmd":86400,"state":{"readonly":{"timestamp":"2016-11-30T13:24:18.33Z","final_tree_head":{"sha256_root_hash":"LcGcZRsm+LGYmrlyC5LXhV1T6OD8iH5dNlb0sEJl9bA=","tree_size":46466472}}},"temporal_interval":{"start_inclusive":"2014-03-07T11:06:00Z","end_exclusive":"2015-03-07T12:00:00Z"}},` + + `{"description":"Google 'Icarus' log","log_id":"KTxRllTIOWW6qlD8WAfUt2+/WHopctykwwz05UVH9Hg=","key":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAETtK8v7MICve56qTHHDhhBOuV4IlUaESxZryCfk9QbG9co/CqPvTsgPDbCpp6oFtyAHwlDhnvr7JijXRD9Cb2FA==","url":"https://ct.googleapis.com/icarus/","dns":"icarus.ct.googleapis.com","mmd":86400,"state":{"usable":{"timestamp":"2018-02-27T00:00:00Z"}}},` + + `{"description":"Google 'Racketeer' log","log_id":"7kEv4llINIlh4vPgjGgugT7A/3cLbXUXF2OvMBT/l2g=","key":"Hy2TPTZ2yq9ASMmMZiB9SZEUx5WNH5G0Ft5Tm9vKMcPXA+ic/Ap3gg6fXzBJR8zLkt5lQjvKMdbHYMGv7yrsZg==","url":"https://ct.googleapis.com/racketeer/","dns":"racketeer.ct.googleapis.com","mmd":86400},` + + `{"description":"Google 'Rocketeer' log","log_id":"7ku9t3XOYLrhQmkfq+GeZqMPfl+wctiDAMR7iXqo/cs=","key":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIFsYyDzBi7MxCAC/oJBXK7dHjG+1aLCOkHjpoHPqTyghLpzA9BYbqvnV16mAw04vUjyYASVGJCUoI3ctBcJAeg==","url":"https://ct.googleapis.com/rocketeer/","dns":"rocketeer.ct.googleapis.com","mmd":86400},` + + `{"description":"Google 'Argon2020' log","log_id": "sh4FzIuizYogTodm+Su5iiUgZ2va+nDnsklTLe+LkF4=","key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6Tx2p1yKY4015NyIYvdrk36es0uAc1zA4PQ+TGRY+3ZjUTIYY9Wyu+3q/147JG4vNVKLtDWarZwVqGkg6lAYzA==","url":"https://ct.googleapis.com/logs/argon2020/","dns":"argon2020.ct.googleapis.com","mmd":86400,"state":{"qualified":{"timestamp":"2018-02-27T00:00:00Z"}},"temporal_interval":{"start_inclusive":"2020-01-01T00:00:00Z","end_exclusive":"2021-01-01T00:00:00Z"}}]},` + + `{"name":"Bob's CT Log Shop","email":["bob@example.com"],"logs":[` + + `{"description":"Bob's Dubious Log","log_id":"zbUXm3/BwEb+6jETaj+PAC5hgvr4iW/syLL1tatgSQA=","key":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECyPLhWKYYUgEc+tUXfPQB4wtGS2MNvXrjwFCCnyYJifBtd2Sk7Cu+Js9DNhMTh35FftHaHu6ZrclnNBKwmbbSA==","url":"https://log.bob.io","dns":"dubious-bob.ct.googleapis.com","mmd":86400,"previous_operators":[ {"name":"Alice's Shady Log","end_time":"2014-11-06T12:00:00Z"}],"state":{"retired":{"timestamp":"2016-04-15T00:00:00Z"}},"temporal_interval":{"start_inclusive":"2014-11-07T12:00:00Z","end_exclusive":"2015-03-07T12:00:00Z"}}]}]}`, + }, + } + + const jsonPrefix = "" + const jsonIndent = " " + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := json.MarshalIndent(&test.in, jsonPrefix, jsonIndent) + if err != nil { + if test.wantErr == "" { + t.Errorf("json.Marshal()=nil,%v; want _,nil", err) + } else if !strings.Contains(err.Error(), test.wantErr) { + t.Errorf("json.Marshal()=nil,%v; want nil,err containing %q", err, test.wantErr) + } + return + } + if test.wantErr != "" { + t.Errorf("json.Marshal()=%q,nil; want nil,err containing %q", got, test.wantErr) + } + // Format `test.want` in the same way as `got` + var wantIndented bytes.Buffer + if err := json.Indent(&wantIndented, []byte(test.want), jsonPrefix, jsonIndent); err != nil { + t.Fatalf("json.Indent(test.want) = %q, want nil", err) + } + dmp := diffmatchpatch.New() + if diffs := dmp.DiffMain(wantIndented.String(), string(got), false); len(diffs) > 1 { + t.Errorf("json.Marshal(): diff \n%s", dmp.DiffPrettyText(diffs)) + } + }) + } +} + +func TestFindLogByName(t *testing.T) { + var tests = []struct { + name, in string + want int + }{ + {name: "Single", in: "Dubious", want: 1}, + {name: "SingleDifferentCase", in: "DUBious", want: 1}, + {name: "Multiple", in: "Google", want: 5}, + {name: "None", in: "Llamalog", want: 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := sampleLogList.FindLogByName(test.in) + if len(got) != test.want { + t.Errorf("len(FindLogByName(%q)=%d, want %d", test.in, len(got), test.want) + } + }) + } +} + +func TestFindLogByURL(t *testing.T) { + var tests = []struct { + name, in, want string + }{ + {name: "NotFound", in: "nowhere.com"}, + {name: "Found//", in: "https://ct.googleapis.com/icarus/", want: "Google 'Icarus' log"}, + {name: "Found./", in: "https://ct.googleapis.com/icarus", want: "Google 'Icarus' log"}, + {name: "Found/.", in: "https://log.bob.io/", want: "Bob's Dubious Log"}, + {name: "Found..", in: "https://log.bob.io", want: "Bob's Dubious Log"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + l := sampleLogList.FindLogByURL(test.in) + got := "" + if l != nil { + got = l.Description + } + if got != test.want { + t.Errorf("FindLogByURL(%q)=%q, want %q", test.in, got, test.want) + } + }) + } +} + +func TestFindLogByKeyhash(t *testing.T) { + var tests = []struct { + name string + in []byte + want string + }{ + { + name: "NotFound", + in: []byte{0xaa, 0xbb, 0xcc}, + }, + { + name: "FoundRocketeer", + in: []byte{ + 0xee, 0x4b, 0xbd, 0xb7, 0x75, 0xce, 0x60, 0xba, 0xe1, 0x42, 0x69, 0x1f, 0xab, 0xe1, 0x9e, 0x66, + 0xa3, 0x0f, 0x7e, 0x5f, 0xb0, 0x72, 0xd8, 0x83, 0x00, 0xc4, 0x7b, 0x89, 0x7a, 0xa8, 0xfd, 0xcb, + }, + want: "Google 'Rocketeer' log", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var hash [sha256.Size]byte + copy(hash[:], test.in) + l := sampleLogList.FindLogByKeyHash(hash) + got := "" + if l != nil { + got = l.Description + } + if got != test.want { + t.Errorf("FindLogByKeyHash(%x)=%q, want %q", test.in, got, test.want) + } + }) + } +} + +func TestFindLogByKeyhashPrefix(t *testing.T) { + var tests = []struct { + name, in string + want []string + }{ + { + name: "NotFound", + in: "aabbcc", + want: []string{}, + }, + { + name: "FoundRocketeer", + in: "ee4b", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundRocketeerOdd", + in: "ee4bb", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundMultiple", + in: "ee4", + want: []string{"Google 'Racketeer' log", "Google 'Rocketeer' log"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + logs := sampleLogList.FindLogByKeyHashPrefix(test.in) + got := make([]string, len(logs)) + for i, l := range logs { + got[i] = l.Description + } + sort.Strings(got) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("FindLogByKeyHash(%x)=%q, want %q", test.in, got, test.want) + } + }) + } +} + +func TestFindLogByKey(t *testing.T) { + var tests = []struct { + name string + in []byte + want string + }{ + { + name: "NotFound", + in: []byte{0xaa, 0xbb, 0xcc}, + }, + { + name: "FoundRocketeer", + in: deb64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIFsYyDzBi7MxCAC/oJBXK7dHjG+1aLCOkHjpoHPqTyghLpzA9BYbqvnV16mAw04vUjyYASVGJCUoI3ctBcJAeg=="), + want: "Google 'Rocketeer' log", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + l := sampleLogList.FindLogByKey(test.in) + got := "" + if l != nil { + got = l.Description + } + if got != test.want { + t.Errorf("FindLogByKey(%x)=%q, want %q", test.in, got, test.want) + } + }) + } +} + +func TestFuzzyFindLog(t *testing.T) { + var tests = []struct { + name, in string + want []string + }{ + { + name: "NotFound", + in: "aabbcc", + want: []string{}, + }, + { + name: "FoundByKey64", + in: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIFsYyDzBi7MxCAC/oJBXK7dHjG+1aLCOkHjpoHPqTyghLpzA9BYbqvnV16mAw04vUjyYASVGJCUoI3ctBcJAeg==", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundByKeyHex", + in: "3059301306072a8648ce3d020106082a8648ce3d03010703420004205b18c83cc18bb3310800bfa090572bb7478c6fb568b08e9078e9a073ea4f28212e9cc0f4161baaf9d5d7a980c34e2f523c9801254624252823772d05c2407a", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundByKeyHashHex", + in: " ee 4b bd b7 75 ce 60 ba e1 42 69 1f ab e1 9e 66 a3 0f 7e 5f b0 72 d8 83 00 c4 7b 89 7a a8 fd cb", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundByKeyHashHexPrefix", + in: "ee4bbdb7", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundByKeyHash64", + in: "7ku9t3XOYLrhQmkfq+GeZqMPfl+wctiDAMR7iXqo/cs=", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundByName", + in: "Rocketeer", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundByNameDifferentCase", + in: "rocketeer", + want: []string{"Google 'Rocketeer' log"}, + }, + { + name: "FoundByURL", + in: "https://ct.googleapis.com/rocketeer", + want: []string{"Google 'Rocketeer' log"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + logs := sampleLogList.FuzzyFindLog(test.in) + got := make([]string, len(logs)) + for i, l := range logs { + got[i] = l.Description + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("FuzzyFindLog(%q)=%v, want %v", test.in, got, test.want) + } + }) + } +} + +func TestStripInternalSpace(t *testing.T) { + var tests = []struct { + in string + want string + }{ + {in: "xyz", want: "xyz"}, + {in: "x y z", want: "xyz"}, + {in: "x yz ", want: "xyz"}, + {in: " xyz ", want: "xyz"}, + {in: "xy\t\tz", want: "xyz"}, + } + + for _, test := range tests { + got := stripInternalSpace(test.in) + if got != test.want { + t.Errorf("stripInternalSpace(%q)=%q, want %q", test.in, got, test.want) + } + } +} + +func TestLogStatesString(t *testing.T) { + var tests = []struct { + name string + logURL string + want string + }{ + {name: "ReadOnly", logURL: "https://ct.googleapis.com/aviator/", want: "ReadOnlyLogStatus"}, + {name: "Empty", logURL: "https://ct.googleapis.com/racketeer/", want: "UndefinedLogStatus"}, + {name: "Usable", logURL: "https://ct.googleapis.com/icarus/", want: "UsableLogStatus"}, + {name: "Retired", logURL: "https://log.bob.io", want: "RetiredLogStatus"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + l := sampleLogList.FindLogByURL(test.logURL) + if got := l.State.String(); got != test.want { + t.Errorf("%q: Log.State.String() = %s, want %s", test.logURL, got, test.want) + } + }) + } +} + +func TestLogStatesActive(t *testing.T) { + a := &LogState{Timestamp: time.Unix(1460678400, 0).UTC()} + f := &ReadOnlyLogState{ + LogState: LogState{Timestamp: time.Unix(1480512258, 330000000).UTC()}, + FinalTreeHead: TreeHead{ + TreeSize: 46466472, + SHA256RootHash: []byte{}, + }, + } + var tests = []struct { + name string + in *LogStates + wantState *LogState + wantFState *ReadOnlyLogState + }{ + { + name: "Retired", + in: &LogStates{ + Retired: a, + }, + wantState: a, + wantFState: nil, + }, + { + name: "ReadOnly", + in: &LogStates{ + ReadOnly: f, + }, + wantState: nil, + wantFState: f, + }, + { + name: "Qualified", + in: &LogStates{ + Qualified: a, + }, + wantState: a, + wantFState: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotState, gotFState := test.in.Active() + if gotState != test.wantState { + t.Errorf("Log-state from Active() = %q, want %q", gotState, test.wantState) + } + if gotFState != test.wantFState { + t.Errorf("ReadOnly Log-state from Active() = %q, want %q", gotFState, test.wantFState) + } + }) + } +} + +func changeOperatorEmail(op Operator, email string) Operator { + op.Email = make([]string, 1) + op.Email[0] = email + return op +} + +func TestGoogleOperated(t *testing.T) { + var tests = []struct { + in Operator + out bool + }{ + {in: *(sampleLogList.Operators[0]), out: true}, + {in: *sampleLogList.Operators[1], out: false}, + {in: changeOperatorEmail(*sampleLogList.Operators[1], "google-ct-logs@googlegroups"), out: true}, + {in: changeOperatorEmail(*sampleLogList.Operators[0], "operator@googlegroups"), out: false}, + } + for _, test := range tests { + isGoog := test.in.GoogleOperated() + if isGoog != test.out { + t.Errorf("GoogleOperated status for %s is %t, want %t", test.in.Name, isGoog, test.out) + } + } +} + +func deb64(b string) []byte { + data, err := base64.StdEncoding.DecodeString(b) + if err != nil { + panic(fmt.Sprintf("hard-coded test data failed to decode: %v", err)) + } + return data +} diff --git a/loglist3/logstatus_string.go b/loglist3/logstatus_string.go new file mode 100644 index 0000000000..84c7bbdf4e --- /dev/null +++ b/loglist3/logstatus_string.go @@ -0,0 +1,29 @@ +// Code generated by "stringer -type=LogStatus"; DO NOT EDIT. + +package loglist3 + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[UndefinedLogStatus-0] + _ = x[PendingLogStatus-1] + _ = x[QualifiedLogStatus-2] + _ = x[UsableLogStatus-3] + _ = x[ReadOnlyLogStatus-4] + _ = x[RetiredLogStatus-5] + _ = x[RejectedLogStatus-6] +} + +const _LogStatus_name = "UndefinedLogStatusPendingLogStatusQualifiedLogStatusUsableLogStatusReadOnlyLogStatusRetiredLogStatusRejectedLogStatus" + +var _LogStatus_index = [...]uint8{0, 18, 34, 52, 67, 84, 100, 117} + +func (i LogStatus) String() string { + if i < 0 || i >= LogStatus(len(_LogStatus_index)-1) { + return "LogStatus(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _LogStatus_name[_LogStatus_index[i]:_LogStatus_index[i+1]] +}