diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go index 71deab9..b0c45b9 100644 --- a/internal/reporter/reporter.go +++ b/internal/reporter/reporter.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" "github.com/space-code/linkctl/internal/models" + "github.com/space-code/linkctl/internal/validator" "github.com/space-code/linkctl/pkg/iostreams" ) @@ -180,6 +181,45 @@ func PrintLinkInfo(w io.Writer, cs *iostreams.ColorScheme, link *models.DeepLink fmt.Fprintln(w) } +func PrintValidationResult(w io.Writer, cs *iostreams.ColorScheme, result *validator.Result) { + fmt.Fprintf(w, "\n%s Validation report for %s\n", cs.Bold("●"), cs.Bold(result.URL)) + if result.Domain != "" { + fmt.Fprintf(w, " Domain: %s\n", result.Domain) + } + fmt.Fprintln(w) + + if len(result.Issues) == 0 { + fmt.Fprintf(w, "All server-side checks passed successfully!\n\n") + return + } + + hasErrors := false + hasWarnings := false + + for _, issue := range result.Issues { + switch issue.Level { + case "error": + hasErrors = true + fmt.Fprintf(w, "%s\n", cs.Red(issue.Message)) + case "warning": + hasWarnings = true + fmt.Fprintf(w, "%s\n", cs.Yellow(issue.Message)) + default: + fmt.Fprintf(w, "%s\n", issue.Message) + } + } + + fmt.Fprintln(w) + + if hasErrors { + fmt.Fprintf(w, "Validation failed with errors.\n\n") + } else if hasWarnings { + fmt.Fprintf(w, "Validation passed with warnings.\n\n") + } else { + fmt.Fprintf(w, "Validation passed.\n\n") + } +} + func PrintProjectScan(w io.Writer, cs *iostreams.ColorScheme, scan *models.ProjectScan) { icon := "🍎" if scan.Platform == "android" { diff --git a/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 0000000..c4601bc --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,252 @@ +package validator + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const maxAASASizeBytes = 128 * 1024 // 128 KB limit according to Apple spec + +var appIDRegex = regexp.MustCompile(`^[A-Z0-9]{10}\.[a-zA-Z0-9_.-]+$`) + +type Issue struct { + Level string `json:"level"` + Message string `json:"message"` +} + +type Result struct { + URL string `json:"url"` + Domain string `json:"domain"` + FileType string `json:"file_type,omitempty"` + ConfigURL string `json:"config_url,omitempty"` + StatusCode int `json:"status_code,omitempty"` + Issues []Issue `json:"issues"` + Valid bool `json:"valid"` +} + +func (r *Result) HasErrors() bool { + for _, issue := range r.Issues { + if issue.Level == "error" { + return true + } + } + return false +} + +func ValidateDeepLink(rawURL string) (*Result, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil || parsedURL.Host == "" { + return nil, fmt.Errorf("invalid URL: %s", rawURL) + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return &Result{ + URL: rawURL, + Valid: true, + Issues: []Issue{{Level: "info", Message: "Custom scheme detected, no server-side validation needed"}}, + }, nil + } + + result := &Result{ + URL: rawURL, + Domain: parsedURL.Hostname(), + Valid: true, + } + + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec + }, + }, + } + + scheme := parsedURL.Scheme + host := parsedURL.Host + + aasaURL := fmt.Sprintf("%s://%s/.well-known/apple-app-site-association", scheme, host) + checkAASA(client, aasaURL, result) + + result.Valid = !result.HasErrors() + return result, nil +} + +func checkAASA(client *http.Client, targetURL string, res *Result) { + resp, err := client.Get(targetURL) + if err != nil { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: fmt.Sprintf("Failed to fetch AASA: %v", err), + }) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: fmt.Sprintf("AASA endpoint redirected with status %d (Apple requires direct 200 OK without redirects)", resp.StatusCode), + }) + return + } + + if resp.StatusCode != http.StatusOK { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: fmt.Sprintf("AASA HTTP status %d (expected 200)", resp.StatusCode), + }) + return + } + + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + res.Issues = append(res.Issues, Issue{ + Level: "warning", + Message: fmt.Sprintf("AASA Content-Type is '%s' (expected 'application/json')", contentType), + }) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAASASizeBytes+1)) + if err != nil { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: "Failed to read AASA response body", + }) + return + } + + if len(body) > maxAASASizeBytes { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: fmt.Sprintf("AASA file size exceeds the 128 KB limit (%d bytes)", len(body)), + }) + return + } + + var aasaContent map[string]interface{} + if err := json.Unmarshal(body, &aasaContent); err != nil { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: "AASA is not valid JSON", + }) + return + } + + applinksRaw, ok := aasaContent["applinks"] + if !ok { + res.Issues = append(res.Issues, Issue{ + Level: "warning", + Message: "AASA JSON does not contain 'applinks' key", + }) + return + } + + applinks, ok := applinksRaw.(map[string]interface{}) + if !ok { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: "'applinks' section must be a JSON object", + }) + return + } + + validateAASAApplinksDetails(applinks, res) +} + +func validateAASAApplinksDetails(applinks map[string]interface{}, res *Result) { + detailsRaw, ok := applinks["details"] + if !ok { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: "'applinks' object is missing 'details' field", + }) + return + } + + var detailsList []map[string]interface{} + + switch v := detailsRaw.(type) { + case []interface{}: + for _, item := range v { + if m, ok := item.(map[string]interface{}); ok { + detailsList = append(detailsList, m) + } + } + case map[string]interface{}: + for appID, data := range v { + if m, ok := data.(map[string]interface{}); ok { + m["appID"] = appID + detailsList = append(detailsList, m) + } + } + default: + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: "'details' field in AASA must be an array or object", + }) + return + } + + if len(detailsList) == 0 { + res.Issues = append(res.Issues, Issue{ + Level: "warning", + Message: "'details' array in AASA is empty", + }) + return + } + + for i, entry := range detailsList { + validateAASAEntry(entry, i, res) + } +} + +func validateAASAEntry(entry map[string]interface{}, index int, res *Result) { + var appIDs []string + if appIDStr, ok := entry["appID"].(string); ok { + appIDs = append(appIDs, appIDStr) + } + if appIDsSlice, ok := entry["appIDs"].([]interface{}); ok { + for _, id := range appIDsSlice { + if idStr, ok := id.(string); ok { + appIDs = append(appIDs, idStr) + } + } + } + + if len(appIDs) == 0 { + res.Issues = append(res.Issues, Issue{ + Level: "error", + Message: fmt.Sprintf("AASA details entry #%d does not specify 'appID' or 'appIDs'", index), + }) + } else { + for _, id := range appIDs { + if !appIDRegex.MatchString(id) { + res.Issues = append(res.Issues, Issue{ + Level: "warning", + Message: fmt.Sprintf("App ID '%s' in entry #%d does not match standard . pattern", id, index), + }) + } + } + } + + _, hasComponents := entry["components"] + _, hasPaths := entry["paths"] + + if !hasComponents && !hasPaths { + res.Issues = append(res.Issues, Issue{ + Level: "warning", + Message: fmt.Sprintf("AASA details entry #%d has neither 'components' nor 'paths' defined", index), + }) + } +} diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go new file mode 100644 index 0000000..a81d18b --- /dev/null +++ b/internal/validator/validator_test.go @@ -0,0 +1,55 @@ +package validator_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/space-code/linkctl/internal/validator" +) + +func TestValidateDeepLink_CustomScheme(t *testing.T) { + res, err := validator.ValidateDeepLink("myapp://profile/123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if res.HasErrors() { + t.Errorf("expected custom scheme to be valid without errors") + } +} + +func TestValidateDeepLink_InvalidURL(t *testing.T) { + _, err := validator.ValidateDeepLink("::not-a-url") + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestValidateDeepLink_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/apple-app-site-association": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"applinks": {"details": []}}`) + case "/.well-known/assetlinks.json": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `[{"relation": ["delegate_permission/common.handle_all_urls"]}]`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + res, err := validator.ValidateDeepLink(server.URL + "/test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if res.HasErrors() { + t.Errorf("expected validation to pass, got issues: %+v", res.Issues) + } +} diff --git a/pkg/cmd/debug/debug.go b/pkg/cmd/debug/debug.go new file mode 100644 index 0000000..8dc17c6 --- /dev/null +++ b/pkg/cmd/debug/debug.go @@ -0,0 +1 @@ +package debug diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 8e7ea1e..1a51ad4 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -4,6 +4,7 @@ import ( checkCmd "github.com/space-code/linkctl/pkg/cmd/check" devicesCmd "github.com/space-code/linkctl/pkg/cmd/devices" scanCmd "github.com/space-code/linkctl/pkg/cmd/scan" + validateCmd "github.com/space-code/linkctl/pkg/cmd/validate" versionCmd "github.com/space-code/linkctl/pkg/cmd/version" "github.com/space-code/linkctl/pkg/cmdutil" "github.com/spf13/cobra" @@ -23,6 +24,7 @@ func NewCmdRoot(f *cmdutil.Factory, appVersion string) (*cobra.Command, error) { cmd.AddCommand(devicesCmd.NewCmdDevices(f)) cmd.AddCommand(checkCmd.NewCmdCheck(f)) cmd.AddCommand(scanCmd.NewCmdScan(f)) + cmd.AddCommand(validateCmd.NewCmdValidate(f)) return cmd, nil } diff --git a/pkg/cmd/validate/validate.go b/pkg/cmd/validate/validate.go new file mode 100644 index 0000000..3ca852e --- /dev/null +++ b/pkg/cmd/validate/validate.go @@ -0,0 +1,70 @@ +package validate + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/space-code/linkctl/internal/reporter" + "github.com/space-code/linkctl/internal/validator" + "github.com/space-code/linkctl/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type options struct { + asJSON bool +} + +func NewCmdValidate(f *cmdutil.Factory) *cobra.Command { + opts := &options{} + + cmd := &cobra.Command{ + Use: "validate ", + Short: "Validate server-side deep link configuration (AASA)", + Long: `Validates the server-side configuration for a given deep link. +Fetches apple-app-site-association (iOS) over the network +and verifies domain health, SSL certificate, HTTP headers, and JSON structure. + +Exits with code 0 when validation passes without errors, 1 otherwise.`, + Args: cobra.ExactArgs(1), + Example: ` linkctl validate https://example.com/profile + linkctl validate https://example.com/app/path --json`, + RunE: func(cmd *cobra.Command, args []string) error { + link := args[0] + return run(f, link, opts) + }, + } + + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output results as JSON") + + return cmd +} + +func run(f *cmdutil.Factory, link string, opts *options) error { + result, err := validator.ValidateDeepLink(link) + if err != nil { + return fmt.Errorf("validate: %w", err) + } + + if opts.asJSON { + enc := json.NewEncoder(f.IOStreams.Out) + enc.SetIndent("", " ") + if err := enc.Encode(result); err != nil { + return fmt.Errorf("encoding JSON: %w", err) + } + if result.HasErrors() { + os.Exit(1) + } + return nil + } + + w := f.IOStreams.Out + cs := f.IOStreams.ColorScheme() + + reporter.PrintValidationResult(w, cs, result) + + if result.HasErrors() { + os.Exit(1) + } + return nil +} diff --git a/pkg/cmd/validate/validate_test.go b/pkg/cmd/validate/validate_test.go new file mode 100644 index 0000000..888c708 --- /dev/null +++ b/pkg/cmd/validate/validate_test.go @@ -0,0 +1,117 @@ +package validate_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/space-code/linkctl/pkg/cmd/validate" + "github.com/space-code/linkctl/pkg/cmdutil" + "github.com/space-code/linkctl/pkg/iostreams" +) + +func newFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) { + t.Helper() + + ios, _, stdout, _ := iostreams.Test() + + f := &cmdutil.Factory{ + AppVersion: "1.0.0", + ExecutableName: "linkctl", + IOStreams: ios, + } + + return f, stdout +} + +func createMockValidationServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/.well-known/apple-app-site-association": + fmt.Fprintln(w, `{"applinks": {"details": []}}`) + case "/.well-known/assetlinks.json": + fmt.Fprintln(w, `[{"relation": ["delegate_permission/common.handle_all_urls"]}]`) + default: + http.NotFound(w, r) + } + })) +} + +func TestValidateCmd_MissingArgs(t *testing.T) { + f, _ := newFactory(t) + cmd := validate.NewCmdValidate(f) + cmd.SetArgs([]string{}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when link argument is missing") + } +} + +func TestValidateCmd_TooManyArgs(t *testing.T) { + f, _ := newFactory(t) + cmd := validate.NewCmdValidate(f) + cmd.SetArgs([]string{"https://example.com/1", "https://example.com/2"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when passing more than 1 argument") + } +} + +func TestValidateCmd_UnknownFlag(t *testing.T) { + f, _ := newFactory(t) + cmd := validate.NewCmdValidate(f) + cmd.SetArgs([]string{"https://example.com", "--invalid-flag"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for unknown flag") + } +} + +func TestValidateCmd_Execution_JSON(t *testing.T) { + ts := createMockValidationServer() + defer ts.Close() + + f, stdout := newFactory(t) + cmd := validate.NewCmdValidate(f) + + targetURL := ts.URL + "/profile" + cmd.SetArgs([]string{targetURL, "--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error during execution: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\ngot: %s", err, stdout.String()) + } + + valid, ok := result["valid"].(bool) + if !ok { + t.Fatalf("expected 'valid' boolean field in output, got: %v", result) + } + + if !valid { + t.Errorf("expected validation to succeed for mock server, got valid=false") + } +} + +func TestValidateCmd_Execution_TextOutput(t *testing.T) { + ts := createMockValidationServer() + defer ts.Close() + + f, stdout := newFactory(t) + cmd := validate.NewCmdValidate(f) + + targetURL := ts.URL + "/profile" + cmd.SetArgs([]string{targetURL}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error during execution: %v", err) + } + + if stdout.Len() == 0 { + t.Error("expected text reporter output, got empty stdout") + } +}