feat(types): add FederatedMessage type for inter-town protocol (bd-wkumz.11)
Add message types for federation protocol: - work-handoff, query, reply, broadcast, ack, reject - Sender trust and staked reputation fields - Cryptographic signature support - Comprehensive validation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
270
internal/types/federation.go
Normal file
270
internal/types/federation.go
Normal file
@@ -0,0 +1,270 @@
|
||||
// Package types defines core data structures for the bd issue tracker.
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FederatedMessageType categorizes inter-town communication.
|
||||
type FederatedMessageType string
|
||||
|
||||
// Federated message type constants
|
||||
const (
|
||||
// MsgWorkHandoff delegates work from one town to another.
|
||||
MsgWorkHandoff FederatedMessageType = "work-handoff"
|
||||
|
||||
// MsgQuery requests information from a peer town.
|
||||
// Used for issue lookups, status checks, and capability discovery.
|
||||
MsgQuery FederatedMessageType = "query"
|
||||
|
||||
// MsgReply responds to a query message.
|
||||
// Contains the requested data or an error explanation.
|
||||
MsgReply FederatedMessageType = "reply"
|
||||
|
||||
// MsgBroadcast announces information to all peer towns.
|
||||
// Used for status updates, capability changes, and town-wide notifications.
|
||||
MsgBroadcast FederatedMessageType = "broadcast"
|
||||
|
||||
// MsgAck acknowledges receipt and acceptance of a message.
|
||||
MsgAck FederatedMessageType = "ack"
|
||||
|
||||
// MsgReject indicates a message was received but rejected.
|
||||
// Includes reason for rejection (invalid, unauthorized, capacity, etc.)
|
||||
MsgReject FederatedMessageType = "reject"
|
||||
)
|
||||
|
||||
// validMessageTypes is the set of allowed message type values
|
||||
var validMessageTypes = map[FederatedMessageType]bool{
|
||||
MsgWorkHandoff: true,
|
||||
MsgQuery: true,
|
||||
MsgReply: true,
|
||||
MsgBroadcast: true,
|
||||
MsgAck: true,
|
||||
MsgReject: true,
|
||||
}
|
||||
|
||||
// IsValid checks if the message type value is valid.
|
||||
func (t FederatedMessageType) IsValid() bool {
|
||||
return validMessageTypes[t]
|
||||
}
|
||||
|
||||
// String returns the string representation of the message type.
|
||||
func (t FederatedMessageType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// FederatedMessage represents a message exchanged between federated towns.
|
||||
// Messages are cryptographically signed for authenticity verification.
|
||||
type FederatedMessage struct {
|
||||
// ===== Core Identity =====
|
||||
|
||||
// ID is a unique identifier for this message (UUID or similar).
|
||||
ID string `json:"id"`
|
||||
|
||||
// Type categorizes the message purpose.
|
||||
Type FederatedMessageType `json:"type"`
|
||||
|
||||
// Timestamp is when the message was created.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// ===== Routing =====
|
||||
|
||||
// Sender identifies the originating town/entity.
|
||||
Sender *EntityRef `json:"sender"`
|
||||
|
||||
// Recipient identifies the target town/entity (nil for broadcasts).
|
||||
Recipient *EntityRef `json:"recipient,omitempty"`
|
||||
|
||||
// ReplyTo links this message to a previous message (for replies, acks, rejects).
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
|
||||
// ===== Payload =====
|
||||
|
||||
// Subject is a brief description of the message content.
|
||||
Subject string `json:"subject,omitempty"`
|
||||
|
||||
// Payload contains the message-specific data (JSON-encoded).
|
||||
// For work-handoff: issue data
|
||||
// For query: query parameters
|
||||
// For reply: response data
|
||||
// For broadcast: announcement data
|
||||
// For ack/reject: processing result
|
||||
Payload string `json:"payload,omitempty"`
|
||||
|
||||
// BeadID references a specific bead if applicable.
|
||||
BeadID string `json:"bead_id,omitempty"`
|
||||
|
||||
// ===== Security =====
|
||||
|
||||
// Signature is the cryptographic signature of the message content.
|
||||
// Format: "<algorithm>:<base64-encoded-signature>"
|
||||
// Example: "ed25519:ABC123..."
|
||||
Signature string `json:"signature,omitempty"`
|
||||
|
||||
// SignerKeyID identifies which key was used to sign (for key rotation).
|
||||
SignerKeyID string `json:"signer_key_id,omitempty"`
|
||||
|
||||
// ===== Rejection Details (for MsgReject) =====
|
||||
|
||||
// RejectReason explains why a message was rejected.
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
|
||||
// RejectCode is a machine-readable rejection code.
|
||||
RejectCode RejectCode `json:"reject_code,omitempty"`
|
||||
}
|
||||
|
||||
// RejectCode categorizes rejection reasons for machine processing.
|
||||
type RejectCode string
|
||||
|
||||
// Rejection code constants
|
||||
const (
|
||||
// RejectInvalid indicates the message format or content is invalid.
|
||||
RejectInvalid RejectCode = "invalid"
|
||||
|
||||
// RejectUnauthorized indicates the sender lacks permission.
|
||||
RejectUnauthorized RejectCode = "unauthorized"
|
||||
|
||||
// RejectCapacity indicates the recipient is at capacity.
|
||||
RejectCapacity RejectCode = "capacity"
|
||||
|
||||
// RejectNotFound indicates the referenced resource doesn't exist.
|
||||
RejectNotFound RejectCode = "not_found"
|
||||
|
||||
// RejectTimeout indicates the message or referenced work has expired.
|
||||
RejectTimeout RejectCode = "timeout"
|
||||
|
||||
// RejectDuplicate indicates this message was already processed.
|
||||
RejectDuplicate RejectCode = "duplicate"
|
||||
)
|
||||
|
||||
// validRejectCodes is the set of allowed reject code values
|
||||
var validRejectCodes = map[RejectCode]bool{
|
||||
RejectInvalid: true,
|
||||
RejectUnauthorized: true,
|
||||
RejectCapacity: true,
|
||||
RejectNotFound: true,
|
||||
RejectTimeout: true,
|
||||
RejectDuplicate: true,
|
||||
}
|
||||
|
||||
// IsValid checks if the reject code value is valid.
|
||||
func (c RejectCode) IsValid() bool {
|
||||
return validRejectCodes[c]
|
||||
}
|
||||
|
||||
// String returns the string representation of the reject code.
|
||||
func (c RejectCode) String() string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
// Validate checks if the FederatedMessage has valid field values.
|
||||
func (m *FederatedMessage) Validate() error {
|
||||
if m.ID == "" {
|
||||
return fmt.Errorf("message ID is required")
|
||||
}
|
||||
if !m.Type.IsValid() {
|
||||
return fmt.Errorf("invalid message type: %s", m.Type)
|
||||
}
|
||||
if m.Timestamp.IsZero() {
|
||||
return fmt.Errorf("timestamp is required")
|
||||
}
|
||||
if m.Sender == nil || m.Sender.IsEmpty() {
|
||||
return fmt.Errorf("sender is required")
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch m.Type {
|
||||
case MsgReply, MsgAck, MsgReject:
|
||||
if m.ReplyTo == "" {
|
||||
return fmt.Errorf("%s message requires reply_to field", m.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation for reject messages
|
||||
if m.Type == MsgReject && m.RejectCode != "" && !m.RejectCode.IsValid() {
|
||||
return fmt.Errorf("invalid reject code: %s", m.RejectCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsResponse returns true if this message type is a response to another message.
|
||||
func (t FederatedMessageType) IsResponse() bool {
|
||||
return t == MsgReply || t == MsgAck || t == MsgReject
|
||||
}
|
||||
|
||||
// RequiresRecipient returns true if this message type requires a specific recipient.
|
||||
func (t FederatedMessageType) RequiresRecipient() bool {
|
||||
// Broadcasts don't require a specific recipient
|
||||
return t != MsgBroadcast
|
||||
}
|
||||
|
||||
// WorkHandoffPayload is the payload structure for work-handoff messages.
|
||||
// Embedded in FederatedMessage.Payload as JSON.
|
||||
type WorkHandoffPayload struct {
|
||||
// Issue is the issue being handed off.
|
||||
Issue *Issue `json:"issue"`
|
||||
|
||||
// Labels are the issue's labels.
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
|
||||
// Dependencies are the issue's dependencies.
|
||||
Dependencies []*Dependency `json:"dependencies,omitempty"`
|
||||
|
||||
// Reason explains why the work is being handed off.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
|
||||
// Deadline is when the work should be completed.
|
||||
Deadline *time.Time `json:"deadline,omitempty"`
|
||||
|
||||
// Priority override for the receiving town (optional).
|
||||
PriorityOverride *int `json:"priority_override,omitempty"`
|
||||
}
|
||||
|
||||
// QueryPayload is the payload structure for query messages.
|
||||
type QueryPayload struct {
|
||||
// QueryType identifies the kind of query.
|
||||
// Examples: "issue-status", "capability-check", "bead-lookup"
|
||||
QueryType string `json:"query_type"`
|
||||
|
||||
// Parameters contains query-specific parameters.
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// ReplyPayload is the payload structure for reply messages.
|
||||
type ReplyPayload struct {
|
||||
// Success indicates whether the query was successful.
|
||||
Success bool `json:"success"`
|
||||
|
||||
// Data contains the query response (type depends on query).
|
||||
Data string `json:"data,omitempty"`
|
||||
|
||||
// Error contains error details if Success is false.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AckPayload is the payload structure for acknowledgment messages.
|
||||
type AckPayload struct {
|
||||
// Accepted indicates whether the message was accepted for processing.
|
||||
Accepted bool `json:"accepted"`
|
||||
|
||||
// ProcessingID is an optional ID for tracking the processing.
|
||||
ProcessingID string `json:"processing_id,omitempty"`
|
||||
|
||||
// EstimatedCompletion is when the work is expected to complete.
|
||||
EstimatedCompletion *time.Time `json:"estimated_completion,omitempty"`
|
||||
}
|
||||
|
||||
// BroadcastPayload is the payload structure for broadcast messages.
|
||||
type BroadcastPayload struct {
|
||||
// BroadcastType categorizes the broadcast.
|
||||
// Examples: "status-update", "capability-change", "town-announcement"
|
||||
BroadcastType string `json:"broadcast_type"`
|
||||
|
||||
// Message is the broadcast content.
|
||||
Message string `json:"message"`
|
||||
|
||||
// Metadata contains additional broadcast-specific data.
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
366
internal/types/federation_test.go
Normal file
366
internal/types/federation_test.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFederatedMessageTypeIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
msgType FederatedMessageType
|
||||
valid bool
|
||||
}{
|
||||
{MsgWorkHandoff, true},
|
||||
{MsgQuery, true},
|
||||
{MsgReply, true},
|
||||
{MsgBroadcast, true},
|
||||
{MsgAck, true},
|
||||
{MsgReject, true},
|
||||
{FederatedMessageType(""), false},
|
||||
{FederatedMessageType("invalid"), false},
|
||||
{FederatedMessageType("work_handoff"), false}, // underscore not dash
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.msgType), func(t *testing.T) {
|
||||
if got := tt.msgType.IsValid(); got != tt.valid {
|
||||
t.Errorf("FederatedMessageType(%q).IsValid() = %v, want %v", tt.msgType, got, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederatedMessageTypeIsResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
msgType FederatedMessageType
|
||||
isResponse bool
|
||||
}{
|
||||
{MsgWorkHandoff, false},
|
||||
{MsgQuery, false},
|
||||
{MsgReply, true},
|
||||
{MsgBroadcast, false},
|
||||
{MsgAck, true},
|
||||
{MsgReject, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.msgType), func(t *testing.T) {
|
||||
if got := tt.msgType.IsResponse(); got != tt.isResponse {
|
||||
t.Errorf("FederatedMessageType(%q).IsResponse() = %v, want %v", tt.msgType, got, tt.isResponse)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederatedMessageTypeRequiresRecipient(t *testing.T) {
|
||||
tests := []struct {
|
||||
msgType FederatedMessageType
|
||||
requiresRecipient bool
|
||||
}{
|
||||
{MsgWorkHandoff, true},
|
||||
{MsgQuery, true},
|
||||
{MsgReply, true},
|
||||
{MsgBroadcast, false}, // broadcasts don't require specific recipient
|
||||
{MsgAck, true},
|
||||
{MsgReject, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.msgType), func(t *testing.T) {
|
||||
if got := tt.msgType.RequiresRecipient(); got != tt.requiresRecipient {
|
||||
t.Errorf("FederatedMessageType(%q).RequiresRecipient() = %v, want %v", tt.msgType, got, tt.requiresRecipient)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectCodeIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
code RejectCode
|
||||
valid bool
|
||||
}{
|
||||
{RejectInvalid, true},
|
||||
{RejectUnauthorized, true},
|
||||
{RejectCapacity, true},
|
||||
{RejectNotFound, true},
|
||||
{RejectTimeout, true},
|
||||
{RejectDuplicate, true},
|
||||
{RejectCode(""), false},
|
||||
{RejectCode("unknown"), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.code), func(t *testing.T) {
|
||||
if got := tt.code.IsValid(); got != tt.valid {
|
||||
t.Errorf("RejectCode(%q).IsValid() = %v, want %v", tt.code, got, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederatedMessageValidation(t *testing.T) {
|
||||
validSender := &EntityRef{
|
||||
Name: "town-alpha",
|
||||
Platform: "gastown",
|
||||
Org: "acme",
|
||||
ID: "town-alpha",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
msg FederatedMessage
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid work-handoff message",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-001",
|
||||
Type: MsgWorkHandoff,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid query message",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-002",
|
||||
Type: MsgQuery,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid broadcast message",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-003",
|
||||
Type: MsgBroadcast,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid reply message with reply_to",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-004",
|
||||
Type: MsgReply,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
ReplyTo: "msg-002",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid ack message with reply_to",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-005",
|
||||
Type: MsgAck,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
ReplyTo: "msg-001",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid reject message with reply_to",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-006",
|
||||
Type: MsgReject,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
ReplyTo: "msg-001",
|
||||
RejectCode: RejectCapacity,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing ID",
|
||||
msg: FederatedMessage{
|
||||
Type: MsgWorkHandoff,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "message ID is required",
|
||||
},
|
||||
{
|
||||
name: "invalid message type",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-007",
|
||||
Type: FederatedMessageType("invalid"),
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid message type",
|
||||
},
|
||||
{
|
||||
name: "missing timestamp",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-008",
|
||||
Type: MsgWorkHandoff,
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "timestamp is required",
|
||||
},
|
||||
{
|
||||
name: "missing sender",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-009",
|
||||
Type: MsgWorkHandoff,
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "sender is required",
|
||||
},
|
||||
{
|
||||
name: "empty sender",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-010",
|
||||
Type: MsgWorkHandoff,
|
||||
Timestamp: time.Now(),
|
||||
Sender: &EntityRef{},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "sender is required",
|
||||
},
|
||||
{
|
||||
name: "reply without reply_to",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-014",
|
||||
Type: MsgReply,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "reply message requires reply_to field",
|
||||
},
|
||||
{
|
||||
name: "ack without reply_to",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-015",
|
||||
Type: MsgAck,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "ack message requires reply_to field",
|
||||
},
|
||||
{
|
||||
name: "reject without reply_to",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-016",
|
||||
Type: MsgReject,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "reject message requires reply_to field",
|
||||
},
|
||||
{
|
||||
name: "reject with invalid reject code",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-017",
|
||||
Type: MsgReject,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
ReplyTo: "msg-001",
|
||||
RejectCode: RejectCode("unknown"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid reject code",
|
||||
},
|
||||
{
|
||||
name: "valid message with signature",
|
||||
msg: FederatedMessage{
|
||||
ID: "msg-019",
|
||||
Type: MsgWorkHandoff,
|
||||
Timestamp: time.Now(),
|
||||
Sender: validSender,
|
||||
Signature: "ed25519:ABC123...",
|
||||
SignerKeyID: "key-001",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.msg.Validate()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("FederatedMessage.Validate() expected error containing %q, got nil", tt.errMsg)
|
||||
} else if tt.errMsg != "" && !containsString(err.Error(), tt.errMsg) {
|
||||
t.Errorf("FederatedMessage.Validate() error = %q, want error containing %q", err.Error(), tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("FederatedMessage.Validate() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederatedMessageTypeString(t *testing.T) {
|
||||
tests := []struct {
|
||||
msgType FederatedMessageType
|
||||
want string
|
||||
}{
|
||||
{MsgWorkHandoff, "work-handoff"},
|
||||
{MsgQuery, "query"},
|
||||
{MsgReply, "reply"},
|
||||
{MsgBroadcast, "broadcast"},
|
||||
{MsgAck, "ack"},
|
||||
{MsgReject, "reject"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
if got := tt.msgType.String(); got != tt.want {
|
||||
t.Errorf("FederatedMessageType.String() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectCodeString(t *testing.T) {
|
||||
tests := []struct {
|
||||
code RejectCode
|
||||
want string
|
||||
}{
|
||||
{RejectInvalid, "invalid"},
|
||||
{RejectUnauthorized, "unauthorized"},
|
||||
{RejectCapacity, "capacity"},
|
||||
{RejectNotFound, "not_found"},
|
||||
{RejectTimeout, "timeout"},
|
||||
{RejectDuplicate, "duplicate"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
if got := tt.code.String(); got != tt.want {
|
||||
t.Errorf("RejectCode.String() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// containsString checks if s contains substr
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user