Skip to content

Commit e0a8013

Browse files
committed
Merge branch 'enterprise/phase-1'
2 parents c566a05 + f98b274 commit e0a8013

32 files changed

Lines changed: 3131 additions & 146 deletions

cmd/daemon/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ func main() {
2424
registryTLS := flag.Bool("registry-tls", false, "use TLS for registry connection")
2525
registryFingerprint := flag.String("registry-fingerprint", "", "hex SHA-256 fingerprint of registry TLS certificate")
2626
identityPath := flag.String("identity", "", "path to persist Ed25519 identity (enables stable identity across restarts)")
27-
owner := flag.String("owner", "", "owner identifier (email) for key rotation recovery")
27+
email := flag.String("email", "", "email address for account identification and key recovery")
28+
owner := flag.String("owner", "", "(deprecated: use -email) owner identifier for key rotation recovery")
2829
keepalive := flag.Duration("keepalive", 0, "keepalive probe interval (default 30s)")
2930
idleTimeout := flag.Duration("idle-timeout", 0, "idle connection timeout (default 120s)")
3031
synRate := flag.Int("syn-rate-limit", 0, "max SYN packets per second (default 100)")
@@ -62,6 +63,7 @@ func main() {
6263
RegistryTLS: *registryTLS,
6364
RegistryFingerprint: *registryFingerprint,
6465
IdentityPath: *identityPath,
66+
Email: *email,
6567
Owner: *owner,
6668
KeepaliveInterval: *keepalive,
6769
IdleTimeout: *idleTimeout,

internal/account/account.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package account
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/TeoSlayer/pilotprotocol/internal/fsutil"
10+
)
11+
12+
// Account holds persisted account information alongside the identity file.
13+
type Account struct {
14+
Email string `json:"email"`
15+
}
16+
17+
// Save writes the account to disk atomically with 0600 permissions.
18+
func Save(path string, acct *Account) error {
19+
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
20+
return fmt.Errorf("create account dir: %w", err)
21+
}
22+
data, err := json.MarshalIndent(acct, "", " ")
23+
if err != nil {
24+
return fmt.Errorf("marshal account: %w", err)
25+
}
26+
if err := fsutil.AtomicWrite(path, data); err != nil {
27+
return fmt.Errorf("write account: %w", err)
28+
}
29+
// Ensure 0600 permissions
30+
return os.Chmod(path, 0600)
31+
}
32+
33+
// Load reads an account from disk. Returns nil, nil if the file does not exist.
34+
func Load(path string) (*Account, error) {
35+
data, err := os.ReadFile(path)
36+
if err != nil {
37+
if os.IsNotExist(err) {
38+
return nil, nil
39+
}
40+
return nil, fmt.Errorf("read account: %w", err)
41+
}
42+
var acct Account
43+
if err := json.Unmarshal(data, &acct); err != nil {
44+
return nil, fmt.Errorf("unmarshal account: %w", err)
45+
}
46+
return &acct, nil
47+
}
48+
49+
// PathFromIdentity returns the account file path derived from an identity file path.
50+
// If identity is "/var/lib/pilot/identity.json", returns "/var/lib/pilot/account.json".
51+
func PathFromIdentity(identityPath string) string {
52+
return filepath.Join(filepath.Dir(identityPath), "account.json")
53+
}

internal/account/account_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package account
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestSaveAndLoad(t *testing.T) {
10+
dir := t.TempDir()
11+
path := filepath.Join(dir, "account.json")
12+
13+
acct := &Account{Email: "user@example.com"}
14+
if err := Save(path, acct); err != nil {
15+
t.Fatalf("Save: %v", err)
16+
}
17+
18+
loaded, err := Load(path)
19+
if err != nil {
20+
t.Fatalf("Load: %v", err)
21+
}
22+
if loaded == nil {
23+
t.Fatal("Load returned nil")
24+
}
25+
if loaded.Email != "user@example.com" {
26+
t.Errorf("Email = %q, want %q", loaded.Email, "user@example.com")
27+
}
28+
29+
// Check file permissions
30+
info, err := os.Stat(path)
31+
if err != nil {
32+
t.Fatalf("Stat: %v", err)
33+
}
34+
if perm := info.Mode().Perm(); perm != 0600 {
35+
t.Errorf("permissions = %o, want 0600", perm)
36+
}
37+
}
38+
39+
func TestLoadNotFound(t *testing.T) {
40+
dir := t.TempDir()
41+
path := filepath.Join(dir, "nonexistent.json")
42+
43+
acct, err := Load(path)
44+
if err != nil {
45+
t.Fatalf("Load: %v", err)
46+
}
47+
if acct != nil {
48+
t.Errorf("expected nil account for nonexistent file, got %+v", acct)
49+
}
50+
}
51+
52+
func TestSaveOverwrite(t *testing.T) {
53+
dir := t.TempDir()
54+
path := filepath.Join(dir, "account.json")
55+
56+
if err := Save(path, &Account{Email: "old@example.com"}); err != nil {
57+
t.Fatalf("Save: %v", err)
58+
}
59+
if err := Save(path, &Account{Email: "new@example.com"}); err != nil {
60+
t.Fatalf("Save overwrite: %v", err)
61+
}
62+
63+
loaded, err := Load(path)
64+
if err != nil {
65+
t.Fatalf("Load: %v", err)
66+
}
67+
if loaded.Email != "new@example.com" {
68+
t.Errorf("Email = %q, want %q", loaded.Email, "new@example.com")
69+
}
70+
}
71+
72+
func TestPathFromIdentity(t *testing.T) {
73+
cases := []struct {
74+
input string
75+
want string
76+
}{
77+
{"/var/lib/pilot/identity.json", "/var/lib/pilot/account.json"},
78+
{"/home/user/.pilot/identity.json", "/home/user/.pilot/account.json"},
79+
{"identity.json", "account.json"},
80+
}
81+
for _, tc := range cases {
82+
got := PathFromIdentity(tc.input)
83+
if got != tc.want {
84+
t.Errorf("PathFromIdentity(%q) = %q, want %q", tc.input, got, tc.want)
85+
}
86+
}
87+
}
88+
89+
func TestSaveCreatesDirectory(t *testing.T) {
90+
dir := t.TempDir()
91+
path := filepath.Join(dir, "subdir", "account.json")
92+
93+
if err := Save(path, &Account{Email: "user@example.com"}); err != nil {
94+
t.Fatalf("Save with nested dir: %v", err)
95+
}
96+
97+
loaded, err := Load(path)
98+
if err != nil {
99+
t.Fatalf("Load: %v", err)
100+
}
101+
if loaded.Email != "user@example.com" {
102+
t.Errorf("Email = %q, want %q", loaded.Email, "user@example.com")
103+
}
104+
}

internal/validate/email.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package validate
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// Email validates an email address with basic checks:
9+
// non-empty, contains exactly one @, domain has a dot, no spaces.
10+
// This is not a full RFC 5322 parser — just enough to catch typos.
11+
func Email(email string) error {
12+
if email == "" {
13+
return fmt.Errorf("email address is required")
14+
}
15+
if strings.Contains(email, " ") {
16+
return fmt.Errorf("email address must not contain spaces")
17+
}
18+
at := strings.Index(email, "@")
19+
if at < 1 {
20+
return fmt.Errorf("email address must contain @")
21+
}
22+
if strings.Count(email, "@") > 1 {
23+
return fmt.Errorf("email address must contain exactly one @")
24+
}
25+
domain := email[at+1:]
26+
if domain == "" {
27+
return fmt.Errorf("email address must have a domain after @")
28+
}
29+
if !strings.Contains(domain, ".") {
30+
return fmt.Errorf("email domain must contain a dot")
31+
}
32+
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
33+
return fmt.Errorf("email domain must not start or end with a dot")
34+
}
35+
return nil
36+
}

internal/validate/email_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package validate
2+
3+
import "testing"
4+
5+
func TestEmailValid(t *testing.T) {
6+
valid := []string{
7+
"user@example.com",
8+
"admin@pilot.local",
9+
"test+tag@sub.domain.org",
10+
"a@b.co",
11+
}
12+
for _, email := range valid {
13+
if err := Email(email); err != nil {
14+
t.Errorf("Email(%q) = %v, want nil", email, err)
15+
}
16+
}
17+
}
18+
19+
func TestEmailInvalid(t *testing.T) {
20+
cases := []struct {
21+
input string
22+
want string
23+
}{
24+
{"", "required"},
25+
{"noatsign", "must contain @"},
26+
{"@domain.com", "must contain @"},
27+
{"user@", "must have a domain"},
28+
{"user@nodot", "must contain a dot"},
29+
{"user @example.com", "must not contain spaces"},
30+
{"user@ example.com", "must not contain spaces"},
31+
{"user@.example.com", "must not start or end with a dot"},
32+
{"user@example.", "must not start or end with a dot"},
33+
{"a@b@c.com", "exactly one @"},
34+
}
35+
for _, tc := range cases {
36+
err := Email(tc.input)
37+
if err == nil {
38+
t.Errorf("Email(%q) = nil, want error containing %q", tc.input, tc.want)
39+
continue
40+
}
41+
if got := err.Error(); !contains(got, tc.want) {
42+
t.Errorf("Email(%q) = %q, want error containing %q", tc.input, got, tc.want)
43+
}
44+
}
45+
}
46+
47+
func contains(s, substr string) bool {
48+
return len(s) >= len(substr) && containsAt(s, substr)
49+
}
50+
51+
func containsAt(s, substr string) bool {
52+
for i := 0; i <= len(s)-len(substr); i++ {
53+
if s[i:i+len(substr)] == substr {
54+
return true
55+
}
56+
}
57+
return false
58+
}

0 commit comments

Comments
 (0)