Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ internal/web/dist/**/*.js text eol=lf
internal/web/dist/**/*.json text eol=lf
internal/web/dist/**/*.mjs text eol=lf
internal/web/dist/**/*.svg text eol=lf

# vCard conformance fixtures intentionally retain wire-format CRLF bytes.
internal/vcard/testdata/*.vcf -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
internal/vcard/registry/data/*.csv -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ DEFAULT_GOLANGCI_LINT_CACHE := $(shell git rev-parse --path-format=absolute --gi
GOLANGCI_LINT_CACHE ?= $(DEFAULT_GOLANGCI_LINT_CACHE)
export GOLANGCI_LINT_CACHE

.PHONY: build build-release install clean test test-v test-pg fmt lint lint-ci testify-helper-check tidy openapi api-generate openapi-check api-check web-install web-generate web-check web-test web-test-browser web-e2e web-build web-embed web-assets-check smoke-web-release shootout run-shootout install-hooks bench docs-install docs-build docs-serve docs-check docs-screenshots docs-assets-branch docs-generated-assets-branch docs-deploy-staging docs-deploy help
.PHONY: build build-release install clean test test-v test-pg fmt lint lint-ci testify-helper-check tidy openapi api-generate openapi-check api-check web-install web-generate web-check web-test web-test-browser web-e2e web-build web-embed web-assets-check smoke-web-release shootout run-shootout install-hooks bench vcard-registry-check vcard-registry-update docs-install docs-build docs-serve docs-check docs-screenshots docs-assets-branch docs-generated-assets-branch docs-deploy-staging docs-deploy help

# Build the binary (debug)
build: web-embed
Expand Down Expand Up @@ -91,6 +91,13 @@ test-pg:
fi
go test -tags "$(PG_TEST_TAGS)" ./...

# Check or update the vendored IANA vCard Elements registry.
vcard-registry-check:
go run ./internal/vcard/cmd/update-registry

vcard-registry-update:
go run ./internal/vcard/cmd/update-registry --write

# Regenerate the committed OpenAPI schemas and generated Go client.
# api/openapi.yaml is the published OpenAPI 3.1 schema; pkg/client/openapi.yaml
# is the OpenAPI 3.0 schema used by the Go client generator.
Expand Down Expand Up @@ -277,6 +284,8 @@ help:
@echo " lint-ci - Run linter (CI, no auto-fix; also runs testify-helper-check)"
@echo " testify-helper-check - Enforce testify helper usage in assertion-heavy tests"
@echo " tidy - Tidy go.mod"
@echo " vcard-registry-check - Check the vendored IANA vCard registry for drift"
@echo " vcard-registry-update - Update the vendored IANA vCard registry"
@echo " openapi - Regenerate OpenAPI specs and generated Go client"
@echo " openapi-check - Check committed OpenAPI specs and generated Go client are up to date"
@echo " api-check - Alias for openapi-check"
Expand Down
86 changes: 86 additions & 0 deletions internal/beeper/vcard_compat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package beeper

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/msgvault/internal/testutil"
"go.kenn.io/msgvault/internal/vcard"
)

func TestVCardContactUpgradesBeeperParticipantByPhone(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
st := testutil.NewTestStore(t)
resolver := newParticipantResolver(st)

weakID, err := resolver.resolveID("@alice:beeper.local", "")
require.NoError(err)

path := filepath.Join(t.TempDir(), "beeper-contact.vcf")
require.NoError(os.WriteFile(path, []byte(
"BEGIN:VCARD\r\n"+
"VERSION:4.0\r\n"+
"FN:Alice Example\r\n"+
"TEL;VALUE=uri:tel:+1-202-555-0123\r\n"+
"EMAIL:Alice@Example.com\r\n"+
"END:VCARD\r\n",
), 0o600))

contacts, err := vcard.ParseFile(path)
require.NoError(err)
require.Len(contacts, 1)
require.Len(contacts[0].Phones, 1)
require.Len(contacts[0].Emails, 1)

richID, err := resolver.resolveUser(&User{
ID: "@alice:beeper.local",
PhoneNumber: contacts[0].Phones[0],
Email: contacts[0].Emails[0],
FullName: contacts[0].FullName,
})
require.NoError(err)
assert.NotEqual(weakID, richID)

resolvedAgain, err := resolver.resolveID("@alice:beeper.local", "")
require.NoError(err)
assert.Equal(richID, resolvedAgain)
}

func TestVCardEmailReusesExistingBeeperParticipant(t *testing.T) {
require := require.New(t)
st := testutil.NewTestStore(t)
existingID, err := st.EnsureParticipant(
"bob@example.com",
"Existing Mail Contact",
"example.com",
)
require.NoError(err)
resolver := newParticipantResolver(st)

path := filepath.Join(t.TempDir(), "email-contact.vcf")
require.NoError(os.WriteFile(path, []byte(
"BEGIN:VCARD\r\n"+
"VERSION:4.0\r\n"+
"FN:Bob Example\r\n"+
"EMAIL:Bob@Example.com\r\n"+
"END:VCARD\r\n",
), 0o600))

contacts, err := vcard.ParseFile(path)
require.NoError(err)
require.Len(contacts, 1)
require.Empty(contacts[0].Phones)
require.Equal([]string{"bob@example.com"}, contacts[0].Emails)

resolvedID, err := resolver.resolveUser(&User{
ID: "@bob:beeper.local",
Email: contacts[0].Emails[0],
FullName: contacts[0].FullName,
})
require.NoError(err)
assert.Equal(t, existingID, resolvedID)
}
245 changes: 245 additions & 0 deletions internal/vcard/cmd/update-registry/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// Command update-registry checks or updates the vendored IANA vCard registry.
package main

import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"go.kenn.io/msgvault/internal/vcard/registry"
)

const (
defaultBaseURL = "https://www.iana.org/assignments/vcard-elements"
defaultDestDir = "internal/vcard/registry/data"
maxResponse = 8 << 20
)

var snapshotNames = []string{
"metadata.json",
"properties.csv",
"parameters.csv",
"value-data-types.csv",
"property-values.csv",
"parameter-values.csv",
}

type options struct {
baseURL string
destDir string
write bool
}

type registryXML struct {
Updated string `xml:"updated"`
}

type registryMetadata struct {
Source string `json:"source"`
Updated string `json:"updated"`
}

func main() {
var write bool
flag.BoolVar(&write, "write", false, "write a validated IANA registry snapshot")
flag.Parse()

client := &http.Client{Timeout: 30 * time.Second}
err := run(context.Background(), client, os.Stdout, options{
baseURL: defaultBaseURL,
destDir: defaultDestDir,
write: write,
})
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func run(
ctx context.Context,
client *http.Client,
stdout io.Writer,
opts options,
) error {
if client == nil {
return errors.New("HTTP client is required")
}
if stdout == nil {
return errors.New("stdout writer is required")
}
opts.baseURL = strings.TrimRight(opts.baseURL, "/")
if opts.baseURL == "" {
return errors.New("base URL is required")
}
if opts.destDir == "" {
return errors.New("destination directory is required")
}

xmlData, err := fetch(ctx, client, opts.baseURL+"/vcard-elements.xml", "vcard-elements.xml")
if err != nil {
return err
}
updated, err := parseUpdated(xmlData)
if err != nil {
return err
}

remote := make(map[string][]byte, len(snapshotNames))
for _, name := range snapshotNames[1:] {
data, fetchErr := fetch(ctx, client, opts.baseURL+"/"+name, name)
if fetchErr != nil {
return fetchErr
}
remote[name] = data
}
metadataBytes, err := json.Marshal(registryMetadata{
Source: defaultBaseURL + "/vcard-elements.xhtml",
Updated: updated,
})
if err != nil {
return fmt.Errorf("encode metadata: %w", err)
}
remote["metadata.json"] = append(metadataBytes, '\n')

if _, err := registry.Parse(filesFromMap(remote)); err != nil {
return fmt.Errorf("validate fetched registry: %w", err)
}

changed, err := changedFiles(opts.destDir, remote)
if err != nil {
return err
}
if len(changed) == 0 {
return nil
}
for _, name := range changed {
if _, err := fmt.Fprintln(stdout, name); err != nil {
return fmt.Errorf("report changed registry files: %w", err)
}
}
if !opts.write {
return fmt.Errorf("IANA vCard registry drift: %s", strings.Join(changed, ", "))
}

if err := os.MkdirAll(opts.destDir, 0o755); err != nil {
return fmt.Errorf("create registry destination: %w", err)
}
for _, name := range changed {
if err := writeAtomic(filepath.Join(opts.destDir, name), remote[name]); err != nil {
return err
}
}
return nil
}

func fetch(
ctx context.Context,
client *http.Client,
url string,
name string,
) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request for %s: %w", name, err)
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", name, err)
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("fetch %s: %s", name, response.Status)
}
data, err := io.ReadAll(io.LimitReader(response.Body, maxResponse+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", name, err)
}
if len(data) > maxResponse {
return nil, fmt.Errorf("%s response exceeds %d bytes", name, maxResponse)
}
return data, nil
}

func parseUpdated(data []byte) (string, error) {
var document registryXML
if err := xml.Unmarshal(data, &document); err != nil {
return "", fmt.Errorf("parse registry XML: %w", err)
}
updated := strings.TrimSpace(document.Updated)
parsed, err := time.Parse("2006-01-02", updated)
if err != nil || parsed.Format("2006-01-02") != updated {
return "", fmt.Errorf("registry update date %q is not YYYY-MM-DD", updated)
}
return updated, nil
}

func filesFromMap(files map[string][]byte) registry.Files {
return registry.Files{
Metadata: files["metadata.json"],
Properties: files["properties.csv"],
Parameters: files["parameters.csv"],
ValueDataTypes: files["value-data-types.csv"],
PropertyValues: files["property-values.csv"],
ParameterValues: files["parameter-values.csv"],
}
}

func changedFiles(destDir string, remote map[string][]byte) ([]string, error) {
var changed []string
for _, name := range snapshotNames {
local, err := os.ReadFile(filepath.Join(destDir, name))
switch {
case err == nil:
if !bytes.Equal(local, remote[name]) {
changed = append(changed, name)
}
case errors.Is(err, os.ErrNotExist):
changed = append(changed, name)
default:
return nil, fmt.Errorf("read local %s: %w", name, err)
}
}
return changed, nil
}

func writeAtomic(dest string, data []byte) (retErr error) {
dir := filepath.Dir(dest)
temp, err := os.CreateTemp(dir, "."+filepath.Base(dest)+".tmp-*")
if err != nil {
return fmt.Errorf("create temporary %s: %w", filepath.Base(dest), err)
}
tempName := temp.Name()
defer func() {
if retErr != nil {
_ = temp.Close()
_ = os.Remove(tempName)
}
}()
if err := temp.Chmod(0o644); err != nil {
return fmt.Errorf("set mode on temporary %s: %w", filepath.Base(dest), err)
}
if _, err := temp.Write(data); err != nil {
return fmt.Errorf("write temporary %s: %w", filepath.Base(dest), err)
}
if err := temp.Sync(); err != nil {
return fmt.Errorf("sync temporary %s: %w", filepath.Base(dest), err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close temporary %s: %w", filepath.Base(dest), err)
}
if err := os.Rename(tempName, dest); err != nil {
return fmt.Errorf("replace %s: %w", filepath.Base(dest), err)
}
return nil
}
Loading