diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2e5720d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# These are supported funding model platforms +github: [christiangda] +liberapay: christiangda +patreon: christiangda +custom: ["https://paypal.me/slashdevops"] diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..8554e6b --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,13 @@ +name: CodeQL config +# queries: +# - name: Run custom queries +# uses: ./queries +# # Run all extra query suites, both because we want to +# # and because it'll act as extra testing. This is why +# # we include both even though one is a superset of the +# # other, because we're testing the parsing logic and +# # that the suites exist in the codeql bundle. +# - uses: security-extended +# - uses: security-and-quality +paths-ignore: + - mocks diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..ca48482 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,51 @@ +# Development Guidelines + +This document contains the critical information about working with the project codebase. +Follows these guidelines precisely to ensure consistency and maintainability of the code. + +## Stack + +- Language: Go (Go 1.22+) +- Framework: Go standard library +- Testing: Go's built-in testing package +- Dependency Management: Go modules +- Version Control: Git +- Documentation: go doc +- Code Review: Pull requests on GitHub +- CI/CD: GitHub Actions +- Logging: `slog` package from the standard library + +## Project Structure + +Since this is a library build in native go, the files are mostly organized following the standard Go project layout with some additional folders for specific functionalities. + +- Library files are located in the root directory. +- examples/ contains example code demonstrating how to use the library. +- .github/ contains GitHub-specific files such as workflows for CI/CD. +- .gitignore specifies files and directories to be ignored by Git. +- .vscode/ contains Visual Studio Code configuration files. +- LICENSE is the license file for the project. +- README.md provides an overview of the project, installation instructions, usage examples, and other relevant information. +- go.mod and go.sum manage the project's dependencies. +- \*.go files contain the main source code of the library. +- \*\_test.go files contain the test cases for the library. + +## Code Style + +- Follow Go's idiomatic style defined in + - #fetch https://google.github.io/styleguide/go/guide + - #fetch https://google.github.io/styleguide/go/decisions + - #fetch https://google.github.io/styleguide/go/best-practices + - #fetch https://golang.org/doc/effective_go.html +- Use meaningful names for variables, functions, and packages. +- Keep functions small and focused on a single task. +- Use comments to explain complex logic or decisions. +- Use dependency injection for services and repositories to facilitate testing and maintainability. +- don't use `interface{}` instead use `any` for better readability. + +## Documentation + +- Use Go's built-in documentation system with `go doc` and comments. +- Document all exported functions, types, and variables with clear and concise comments. +- Use examples in the documentation to illustrate how to use the library effectively. +- Keep documentation up to date with code changes. The package documentation located at `doc.go` should provide an overview of the package and its main functionalities. and the Public documentation at `README.md` should provide an overview of the project, installation instructions, usage examples, and other relevant information. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e0871f9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..39d2e33 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,15 @@ +--- +# https://docs.github.com/es/repositories/releasing-projects-on-github/automatically-generated-release-notes +changelog: + categories: + - title: Breaking Changes πŸ›  + labels: + - Semver-Major + - breaking-change + - title: New Features πŸŽ‰ + labels: + - Semver-Minor + - enhancement + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0dec8f6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,100 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "08 12 * * 4" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # πŸ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..445e9fc --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,117 @@ +name: Main + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + + - name: Summary Information + run: | + echo "# Push Summary" > $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Repository:** ${{ github.repository }}" >> $GITHUB_STEP_SUMMARY + echo "**Push:** ${{ github.event.head_commit.message }}" >> $GITHUB_STEP_SUMMARY + echo "**Author:** ${{ github.event.head_commit.author.name }}" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ github.ref }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Tools and versions + run: | + echo "## Tools and versions" >> $GITHUB_STEP_SUMMARY + + ubuntu_version=$(lsb_release -a 2>&1 | grep "Description" | awk '{print $2, $3, $4}') + echo "Ubuntu version: $ubuntu_version" + echo "**Ubuntu Version:** $ubuntu_version" >> $GITHUB_STEP_SUMMARY + + bash_version=$(bash --version | head -n 1 | awk '{print $4}') + echo "Bash version: $bash_version" + echo "**Bash Version:** $bash_version" >> $GITHUB_STEP_SUMMARY + + git_version=$(git --version | awk '{print $3}') + echo "Git version: $git_version" + echo "**Git Version:** $git_version" >> $GITHUB_STEP_SUMMARY + + go_version=$(go version | awk '{print $3}') + echo "Go version: $go_version" + echo "**Go Version:** $go_version" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Lines of code + env: + GH_TOKEN: ${{ github.token }} + run: | + export TOOL_NAME="scc" + export GIT_ORG="boyter" + export GIT_REPO="scc" + export OS=$(uname -s) + export OS_ARCH=$(uname -m) + # Normalize architecture names to match asset naming + [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" + [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" + export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") + + gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME + + # Extract based on file extension + if [[ "$ASSETS_NAME" == *.tar.gz ]]; then + tar -xzf $ASSETS_NAME + elif [[ "$ASSETS_NAME" == *.zip ]]; then + unzip $ASSETS_NAME + fi + + rm $ASSETS_NAME + + mv $TOOL_NAME ~/go/bin/$TOOL_NAME + ~/go/bin/$TOOL_NAME --version + + # go install github.com/boyter/scc/v3@latest + + scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Test + run: | + echo "### Test report" >> $GITHUB_STEP_SUMMARY + + go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Test coverage + run: | + echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY + + # Generate coverage report using standard library tools + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Coverage report" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.txt | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Calculate total coverage percentage + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> $GITHUB_STEP_SUMMARY + + - name: Build + run: | + echo "## Build" >> $GITHUB_STEP_SUMMARY + + go build ./... | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Build completed successfully." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..aa791fb --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,114 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + + - name: Summary Information + run: | + echo "# Pull Request Summary" > $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Repository:** ${{ github.repository }}" >> $GITHUB_STEP_SUMMARY + echo "**Pull Request:** ${{ github.event.pull_request.title }}" >> $GITHUB_STEP_SUMMARY + echo "**Author:** ${{ github.event.pull_request.user.login }}" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ github.event.pull_request.head.ref }}" >> $GITHUB_STEP_SUMMARY + echo "**Base:** ${{ github.event.pull_request.base.ref }}" >> $GITHUB_STEP_SUMMARY + echo "**Commits:** ${{ github.event.pull_request.commits }}" >> $GITHUB_STEP_SUMMARY + echo "**Changed Files:** ${{ github.event.pull_request.changed_files }}" >> $GITHUB_STEP_SUMMARY + echo "**Additions:** ${{ github.event.pull_request.additions }}" >> $GITHUB_STEP_SUMMARY + echo "**Deletions:** ${{ github.event.pull_request.deletions }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Tools and versions + run: | + echo "## Tools and versions" >> $GITHUB_STEP_SUMMARY + + ubuntu_version=$(lsb_release -a 2>&1 | grep "Description" | awk '{print $2, $3, $4}') + echo "Ubuntu version: $ubuntu_version" + echo "**Ubuntu Version:** $ubuntu_version" >> $GITHUB_STEP_SUMMARY + + bash_version=$(bash --version | head -n 1 | awk '{print $4}') + echo "Bash version: $bash_version" + echo "**Bash Version:** $bash_version" >> $GITHUB_STEP_SUMMARY + + git_version=$(git --version | awk '{print $3}') + echo "Git version: $git_version" + echo "**Git Version:** $git_version" >> $GITHUB_STEP_SUMMARY + + go_version=$(go version | awk '{print $3}') + echo "Go version: $go_version" + echo "**Go Version:** $go_version" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Lines of code + env: + GH_TOKEN: ${{ github.token }} + run: | + export TOOL_NAME="scc" + export GIT_ORG="boyter" + export GIT_REPO="scc" + export OS=$(uname -s) + export OS_ARCH=$(uname -m) + # Normalize architecture names to match asset naming + [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" + [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" + export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") + + gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME + + # Extract based on file extension + if [[ "$ASSETS_NAME" == *.tar.gz ]]; then + tar -xzf $ASSETS_NAME + elif [[ "$ASSETS_NAME" == *.zip ]]; then + unzip $ASSETS_NAME + fi + + rm $ASSETS_NAME + + mv $TOOL_NAME ~/go/bin/$TOOL_NAME + ~/go/bin/$TOOL_NAME --version + + # go install github.com/boyter/scc/v3@latest + + scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Test + run: | + echo "### Test report" >> $GITHUB_STEP_SUMMARY + + go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Test coverage + run: | + echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY + + # Generate coverage report using standard library tools + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Coverage report" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.txt | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Calculate total coverage percentage + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a4276cd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release + +# https://help.github.com/es/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet +on: + push: + tags: + - v[0-9].[0-9]+.[0-9]* + +permissions: + id-token: write + security-events: write + actions: write + contents: write + pull-requests: read + packages: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go 1.x + id: go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + + - name: Summary Information + run: | + echo "# Build Summary" > $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Repository:** ${{ github.repository }}" >> $GITHUB_STEP_SUMMARY + echo "**Who merge:** ${{ github.triggering_actor }}" >> $GITHUB_STEP_SUMMARY + echo "**Commit ID:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Lines of code + env: + GH_TOKEN: ${{ github.token }} + run: | + export TOOL_NAME="scc" + export GIT_ORG="boyter" + export GIT_REPO="scc" + export OS=$(uname -s) + export OS_ARCH=$(uname -m) + # Normalize architecture names to match asset naming + [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" + [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" + export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") + + gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME + + # Extract based on file extension + if [[ "$ASSETS_NAME" == *.tar.gz ]]; then + tar -xzf $ASSETS_NAME + elif [[ "$ASSETS_NAME" == *.zip ]]; then + unzip $ASSETS_NAME + fi + + rm $ASSETS_NAME + + mv $TOOL_NAME ~/go/bin/$TOOL_NAME + ~/go/bin/$TOOL_NAME --version + + # go install github.com/boyter/scc/v3@latest + + scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Test + run: | + echo "### Test report" >> $GITHUB_STEP_SUMMARY + + go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Test coverage + run: | + echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY + + # Generate coverage report using standard library tools + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Coverage report" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.txt | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Calculate total coverage percentage + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> $GITHUB_STEP_SUMMARY + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + draft: false + prerelease: false + generate_release_notes: true + make_latest: true diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..06c4375 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,89 @@ +version: "2" +linters: + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + - errcheck + - ineffassign + - staticcheck + - unused + + # Disable specific linter + # https://golangci-lint.run/usage/linters/#disabled-by-default + disable: + # Enable presets. + # https://golangci-lint.run/usage/linters + # Default: [] + - govet + - godot + - wsl + - testpackage + - whitespace + - tagalign + - nosprintfhostport + - nlreturn + - nestif + - mnd + - misspell + - lll + - godox + - funlen + - gochecknoinits + - depguard + - goconst + - dupword + - cyclop + - gocognit + - maintidx + - gocyclo + - dupl + + settings: + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: false + # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`. + # Such cases aren't reported by default. + # Default: false + check-blank: true + # To disable the errcheck built-in exclude list. + # See `-excludeonly` option in https://github.com/kisielk/errcheck#excluding-functions for details. + # Default: false + disable-default-exclusions: true + # List of functions to exclude from checking, where each entry is a single function to exclude. + # See https://github.com/kisielk/errcheck#excluding-functions for details. + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - io/ioutil.ReadFile + - io.Copy(*bytes.Buffer) + - io.Copy(os.Stdout) + - os.Setenv + - os.Unsetenv + - os.WriteFile + - os.Remove + - os.RemoveAll + - os.MkdirAll + - fmt.Printf + - fmt.Print + - fmt.Println + - fmt.Fprint + - fmt.Fprintln + - fmt.Fprintf + - fmt.Errorf + - (*text/tabwriter.Writer).Flush + - (*strings.Builder).WriteString + - (*strings.Builder).WriteRune + - (hash.Hash).Write + - (io.Writer).Write + - io.ReadAll + - filepath.Match + - github.com/spf13/viper.BindPFlag + - (*github.com/spf13/cobra.Command).MarkFlagRequired + - (*github.com/spf13/pflag.FlagSet).GetString + - (*github.com/spf13/pflag.FlagSet).GetInt + - (*github.com/spf13/pflag.FlagSet).GetBool + - encoding/json.Marshal + - encoding/hex.DecodeString diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5967638 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "cSpell.words": [ + "betteralign", + "golangci", + "govulncheck", + "ioreg", + "machineid", + "OSCPU", + "slashdevops", + "UEFI", + "vulncheck" + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..02dd134 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a420674 --- /dev/null +++ b/Makefile @@ -0,0 +1,269 @@ +.DELETE_ON_ERROR: clean + +EXECUTABLES = go zip shasum +K := $(foreach exec,$(EXECUTABLES),\ + $(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH))) + +# Add .SHELLFLAGS to ensure that shell errors are propagated +.SHELLFLAGS := -e -c + +# this is used to rename the repository when is created from the template +# we will use the git remote url to get the repository name +GIT_REPOSITORY_NAME ?= $(shell git remote get-url origin | cut -d '/' -f 2 | cut -d '.' -f 1) +GIT_REPOSITORY_NAME_UNDERSCORE := $(subst -,_,$(GIT_REPOSITORY_NAME)) + +PROJECT_NAME ?= $(shell grep module go.mod | cut -d '/' -f 3) +PROJECT_NAMESPACE ?= $(shell grep module go.mod | cut -d '/' -f 2 ) +PROJECT_MODULES_PATH := $(shell ls -d cmd/*) +PROJECT_MODULES_NAME := $(foreach dir_name, $(PROJECT_MODULES_PATH), $(shell basename $(dir_name)) ) +PROJECT_DEPENDENCIES := $(shell go list -m -f '{{if not (or .Indirect .Main)}}{{.Path}}{{end}}' all) + +BUILD_DIR := ./build +DIST_DIR := ./dist +DIST_ASSETS_DIR := $(DIST_DIR)/assets + +PROJECT_COVERAGE_FILE ?= $(BUILD_DIR)/coverage.txt +PROJECT_COVERAGE_MODE ?= atomic +PROJECT_COVERAGE_TAGS ?= unit +PROJECT_INTEGRATION_COVERAGE_TAGS ?= integration + +GIT_VERSION ?= $(shell git rev-parse --abbrev-ref HEAD | cut -d "/" -f 2) +GIT_COMMIT ?= $(shell git rev-parse HEAD | tr -d '\040\011\012\015\n') +GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD | tr -d '\040\011\012\015\n') +GIT_USER := $(shell git config --get user.name | tr -d '\040\011\012\015\n') +GIT_USER_EMAIL := $(shell git config --get user.email | tr -d '\040\011\012\015\n') +BUILD_DATE := $(shell date +'%Y-%m-%dT%H:%M:%SZ') + +GO_LDFLAGS_OPTIONS ?= -s -w +define EXTRA_GO_LDFLAGS_OPTIONS +-X '"'github.com/$(PROJECT_NAMESPACE)/$(PROJECT_NAME)/internal/version.Version=$(GIT_VERSION)'"' \ +-X '"'github.com/$(PROJECT_NAMESPACE)/$(PROJECT_NAME)/internal/version.BuildDate=$(BUILD_DATE)'"' \ +-X '"'github.com/$(PROJECT_NAMESPACE)/$(PROJECT_NAME)/internal/version.GitCommit=$(GIT_COMMIT)'"' \ +-X '"'github.com/$(PROJECT_NAMESPACE)/$(PROJECT_NAME)/internal/version.GitBranch=$(GIT_BRANCH)'"' \ +-X '"'github.com/$(PROJECT_NAMESPACE)/$(PROJECT_NAME)/internal/version.BuildUser=$(GIT_USER_EMAIL)'"' +endef + +GO_LDFLAGS := -ldflags "$(GO_LDFLAGS_OPTIONS) $(EXTRA_GO_LDFLAGS_OPTIONS)" +GO_CGO_ENABLED ?= 0 +GO_OPTS ?= -v +GO_OS ?= linux darwin windows +GO_ARCH ?= arm64 amd64 +# avoid mocks in tests +GO_FILES := $(shell go list ./... | grep -v mocks | grep -v docs) +GO_GRAPH_FILE := $(BUILD_DIR)/go-mod-graph.txt + +######## Functions ######## +# this is a function that will execute a command and print a message +# MAKE_DEBUG=true make will print the command +# MAKE_STOP_ON_ERRORS=true make any fail will stop the execution if the command fails, this is useful for CI +# NOTE: if the command has a > it will print the output into the original redirect of the command +MAKE_STOP_ON_ERRORS ?= false +MAKE_DEBUG ?= false + +define exec_cmd +$(if $(filter $(MAKE_DEBUG),true),\ + ${1} \ +, \ + $(if $(filter $(MAKE_STOP_ON_ERRORS),true),\ + @${1} > /dev/null && printf " 🀞 ${1} βœ…\n" || (printf " ${1} ❌ πŸ–•\n"; exit 1) \ + , \ + $(if $(findstring >, $1),\ + @${1} 2>/dev/null; _exit_code=$$?; if [ $$_exit_code -eq 0 ]; then printf " 🀞 ${1} βœ…\n"; else printf " ${1} ❌ πŸ–•\n"; fi; exit $$_exit_code \ + , \ + @${1} > /dev/null 2>&1; _exit_code=$$?; if [ $$_exit_code -eq 0 ]; then printf ' 🀞 ${1} βœ…\n'; else printf ' ${1} ❌ πŸ–•\n'; fi; exit $$_exit_code \ + ) \ + ) \ +) + +endef # don't remove the white line before endef + +############################################################################### +######## Targets ############################################################## +##@ Default command +.PHONY: all +all: clean build ## Clean, test and build the application. Execute by default when make is called without arguments + +############################################################################### +##@ Golang commands +.PHONY: go-fmt +go-fmt: ## Format go code + @printf "πŸ‘‰ Formatting go code...\n" + $(call exec_cmd, go fmt ./... ) + +.PHONY: go-vet +go-vet: ## Vet go code + @printf "πŸ‘‰ Vet go code...\n" + $(call exec_cmd, go vet ./... ) + +.PHONY: go-generate +go-generate: ## Generate go code + @printf "πŸ‘‰ Generating go code...\n" + $(call exec_cmd, go generate ./... ) + +.PHONY: go-mod-tidy +go-mod-tidy: ## Clean go.mod and go.sum + @printf "πŸ‘‰ Cleaning go.mod and go.sum...\n" + $(call exec_cmd, go mod tidy) + +.PHONY: go-mod-update +go-mod-update: go-mod-tidy ## Update go.mod and go.sum + @printf "πŸ‘‰ Updating go.mod and go.sum...\n" + $(foreach DEP, $(PROJECT_DEPENDENCIES), \ + $(call exec_cmd, go get -u $(DEP)) \ + ) + $(call exec_cmd, go mod tidy) + +.PHONY: go-mod-vendor +go-mod-vendor: ## Create mod vendor + @printf "πŸ‘‰ Creating mod vendor...\n" + $(call exec_cmd, go mod vendor) + +.PHONY: go-mod-verify +go-mod-verify: ## Verify go.mod and go.sum + @printf "πŸ‘‰ Verifying go.mod and go.sum...\n" + $(call exec_cmd, go mod verify) + +.PHONY: go-mod-download +go-mod-download: ## Download go dependencies + @printf "πŸ‘‰ Downloading go dependencies...\n" + $(call exec_cmd, go mod download) + +.PHONY: go-mod-graph +go-mod-graph: ## Create a file with the go dependencies graph in build dir + @printf "πŸ‘‰ Printing go dependencies graph...\n" + $(call exec_cmd, go mod graph > $(GO_GRAPH_FILE)) + +.PHONY: go-betteralign +go-betteralign: install-betteralign ## Align go code with betteralign + @printf "πŸ‘‰ Aligning go code with betteralign...\n" + $(call exec_cmd, betteralign -apply ./... ) + +# this target is needed to create the dist folder and the coverage file +$(PROJECT_COVERAGE_FILE): + @printf "πŸ‘‰ Creating coverage file...\n" + $(call exec_cmd, mkdir -p $(BUILD_DIR) ) + $(call exec_cmd, touch $(PROJECT_COVERAGE_FILE) ) + +.PHONY: go-test-coverage +go-test-coverage: test ## Shows in you browser the test coverage report per package + @printf "πŸ‘‰ Running got tool coverage...\n" + $(call exec_cmd, go tool cover -html=$(PROJECT_COVERAGE_FILE)) + +############################################################################### +##@ Test commands +.PHONY: test +test: $(PROJECT_COVERAGE_FILE) go-generate go-mod-tidy go-fmt go-vet ## Run tests + @printf "πŸ‘‰ Running tests...\n" + $(call exec_cmd, go test \ + -v -race \ + -coverprofile=$(PROJECT_COVERAGE_FILE) \ + -covermode=$(PROJECT_COVERAGE_MODE) \ + -tags=$(PROJECT_COVERAGE_TAGS) \ + ./... \ + ) + +.PHONY: test-coverage +test-coverage: install-go-test-coverage ## Run tests and show coverage + @printf "πŸ‘‰ Running tests and showing coverage...\n" + $(call exec_cmd, go-test-coverage --config=./.testcoverage.yml ) + +############################################################################### +##@ Build commands +.PHONY: build +build: go-generate go-fmt go-vet ## Build the API service only + @printf "πŸ‘‰ Building...\n" + $(foreach proj_mod, $(PROJECT_MODULES_NAME), \ + $(call exec_cmd, CGO_ENABLED=$(GO_CGO_ENABLED) go build $(GO_LDFLAGS) $(GO_OPTS) -o $(BUILD_DIR)/$(proj_mod) ./cmd/$(proj_mod)/ ) \ + $(call exec_cmd, chmod +x $(BUILD_DIR)/$(proj_mod) ) \ + ) + +.PHONY: build-all +build-all: lint vulncheck go-generate go-fmt go-vet ## Build all the application including the API service and the CLI + @printf "πŸ‘‰ Building and lintering...\n" + $(foreach proj_mod, $(PROJECT_MODULES_NAME), \ + $(call exec_cmd, CGO_ENABLED=$(GO_CGO_ENABLED) go build $(GO_LDFLAGS) $(GO_OPTS) -o $(BUILD_DIR)/$(proj_mod) ./cmd/$(proj_mod)/ ) \ + $(call exec_cmd, chmod +x $(BUILD_DIR)/$(proj_mod) ) \ + ) + +.PHONY: build-dist +build-dist: ## Build the application for all platforms defined in GO_OS and GO_ARCH in this Makefile + @printf "πŸ‘‰ Building application for different platforms...\n" + $(foreach GOOS, $(GO_OS), \ + $(foreach GOARCH, $(GO_ARCH), \ + $(foreach proj_mod, $(PROJECT_MODULES_NAME), \ + $(call exec_cmd, GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=$(GO_CGO_ENABLED) go build $(GO_LDFLAGS) $(GO_OPTS) -o $(DIST_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH) ./cmd/$(proj_mod)/ ) \ + $(call exec_cmd, chmod +x $(DIST_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH)) \ + )\ + )\ + ) + +.PHONY: build-dist-zip +build-dist-zip: ## Build the application for all platforms defined in GO_OS and GO_ARCH in this Makefile and create a zip file for each binary. Requires make build-dist + @printf "πŸ‘‰ Creating zip files for distribution...\n" + $(call exec_cmd, mkdir -p $(DIST_ASSETS_DIR)) + $(foreach GOOS, $(GO_OS), \ + $(foreach GOARCH, $(GO_ARCH), \ + $(foreach proj_mod, $(PROJECT_MODULES_NAME), \ + $(call exec_cmd, zip --junk-paths -r $(DIST_ASSETS_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH).zip $(DIST_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH) ) \ + $(call exec_cmd, shasum -a 256 $(DIST_ASSETS_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH).zip | cut -d ' ' -f 1 > $(DIST_ASSETS_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH).sha256 ) \ + ) \ + ) \ + ) + +############################################################################### +##@ Check commands +.PHONY: lint +lint: install-golangci-lint ## Run linters + @printf "πŸ‘‰ Running linters...\n" + $(call exec_cmd, golangci-lint run ./...) + +.PHONY: vulncheck +vulncheck: install-govulncheck ## Check vulnerabilities + @printf "πŸ‘‰ Checking vulnerabilities...\n" + $(call exec_cmd, govulncheck ./...) + +############################################################################### +##@ Tools commands +.PHONY: install-go-test-coverage +install-go-test-coverage: ## Install got tool for test coverage (https://github.com/vladopajic/go-test-coverage) + @printf "πŸ‘‰ Installing got tool for test coverage...\n" + $(call exec_cmd, go install github.com/vladopajic/go-test-coverage/v2@latest ) + +.PHONY: install-govulncheck +install-govulncheck: ## Install govulncheck for vulnerabilities check (https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck#section-documentation) + @printf "πŸ‘‰ Installing govulncheck...\n" + $(call exec_cmd, go install golang.org/x/vuln/cmd/govulncheck@latest ) + +.PHONY: install-golangci-lint +install-golangci-lint: ## Install golangci-lint for linting (https://golangci-lint.run/) + @printf "πŸ‘‰ Installing golangci-lint...\n" + $(call exec_cmd, go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2 ) + +.PHONY: install-betteralign +install-betteralign: ## Install betteralign for code alignment (https://github.com/dkorunic/betteralign) + @printf "πŸ‘‰ Installing betteralign...\n" + $(call exec_cmd, go install github.com/dkorunic/betteralign/cmd/betteralign@latest ) + +############################################################################### +##@ Support Commands +.PHONY: clean +clean: ## Clean the environment + @printf "πŸ‘‰ Cleaning environment...\n" + $(call exec_cmd, go clean -n -x -i) + $(call exec_cmd, rm -rf $(BUILD_DIR) $(DIST_DIR) ) + +# Test target to verify error handling +.PHONY: test-fail +test-fail: ## Test target that always fails (for testing error handling) + @printf "πŸ‘‰ Testing error handling...\n" + $(call exec_cmd, false) # 'false' command always returns exit code 1 + @printf "This should not be printed if MAKE_STOP_ON_ERRORS=true\n" + +.PHONY: help +help: ## Display this help + @awk 'BEGIN {FS = ":.*##"; \ + printf "Usage: make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ \ + { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 } /^##@/ \ + { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' \ + $(MAKEFILE_LIST) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0be9bec --- /dev/null +++ b/README.md @@ -0,0 +1,412 @@ +# machineid + +[![main branch](https://github.com/slashdevops/machineid/actions/workflows/main.yml/badge.svg)](https://github.com/slashdevops/machineid/actions/workflows/main.yml) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod-go-version/slashdevops/machineid?style=plastic) +[![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/machineid.svg)](https://pkg.go.dev/github.com/slashdevops/machineid) +[![Go Report Card](https://goreportcard.com/badge/github.com/slashdevops/machineid)](https://goreportcard.com/report/github.com/slashdevops/machineid) +[![license](https://img.shields.io/github/license/slashdevops/machineid.svg)](https://github.com/slashdevops/machineid/blob/main/LICENSE) +[![Release](https://github.com/slashdevops/machineid/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/machineid/actions/workflows/release.yml) + +A **zero-dependency** Go library that generates unique, deterministic machine identifiers from hardware characteristics. IDs are stable across reboots, sensitive to hardware changes, and ideal for software licensing, device fingerprinting, and telemetry correlation. + +## Features + +- **Zero Dependencies** β€” built entirely on the Go standard library +- **Cross-Platform** β€” macOS, Linux, and Windows +- **Configurable** β€” choose which hardware signals to include (CPU, Motherboard, System UUID, MAC, Disk) +- **Power-of-2 Output** β€” 32, 64, 128, or 256 hex characters +- **SHA-256 Hashing** β€” cryptographically secure, no collisions in practice +- **Salt Support** β€” application-specific IDs on the same machine +- **VM Friendly** β€” preset for virtual environments (CPU + UUID) +- **Thread-Safe** β€” safe for concurrent use after configuration +- **Diagnostic API** β€” inspect which components succeeded or failed +- **Optional Logging** β€” `*slog.Logger` support for observability with zero overhead when disabled +- **Testable** β€” dependency-injectable command executor + +## Installation + +### Library + +Add the module to your Go project: + +```bash +go get github.com/slashdevops/machineid +``` + +Requires **Go 1.25+**. No external dependencies. + +### CLI Tool + +#### Using `go install` + +```bash +go install github.com/slashdevops/machineid/cmd/machineid@latest +``` + +Make sure `~/go/bin` is in your `PATH`: + +```bash +mkdir -p ~/go/bin + +# bash +cat >> ~/.bash_profile <> ~/.zshrc < Vulnerability](https://github.com/slashdevops/machineid/issues/new/choose) to report it diff --git a/bios.go b/bios.go new file mode 100644 index 0000000..692b6e3 --- /dev/null +++ b/bios.go @@ -0,0 +1,5 @@ +//go:build linux || windows + +package machineid + +const biosFirmwareMessage string = "To be filled by O.E.M." diff --git a/build/machineid b/build/machineid new file mode 100755 index 0000000..f0ebf55 Binary files /dev/null and b/build/machineid differ diff --git a/cmd/machineid/main.go b/cmd/machineid/main.go new file mode 100644 index 0000000..8fc1967 --- /dev/null +++ b/cmd/machineid/main.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log/slog" + "os" + "runtime/debug" + "strings" + + "github.com/slashdevops/machineid" + "github.com/slashdevops/machineid/internal/version" +) + +const applicationName = "machineid" + +func main() { + // Hardware component flags + cpu := flag.Bool("cpu", false, "Include CPU identifier") + motherboard := flag.Bool("motherboard", false, "Include motherboard serial number") + uuid := flag.Bool("uuid", false, "Include system UUID") + mac := flag.Bool("mac", false, "Include network MAC addresses") + disk := flag.Bool("disk", false, "Include disk serial numbers") + all := flag.Bool("all", false, "Include all hardware identifiers") + vm := flag.Bool("vm", false, "Use VM-friendly mode (CPU + UUID only)") + + // Output options + format := flag.Int("format", 64, "Output format length: 32, 64, 128, or 256 characters") + salt := flag.String("salt", "", "Custom salt for application-specific IDs") + + // Actions + validate := flag.String("validate", "", "Validate a machine ID against the current machine") + diagnostics := flag.Bool("diagnostics", false, "Show diagnostic information about collected components") + jsonOutput := flag.Bool("json", false, "Output result as JSON") + + // Info flags + versionFlag := flag.Bool("version", false, "Show version information") + versionLongFlag := flag.Bool("version.long", false, "Show detailed version information") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "machineid - Generate unique machine identifiers based on hardware characteristics\n\n") + fmt.Fprintf(os.Stderr, "Usage:\n machineid [flags]\n\nFlags:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " machineid -cpu -uuid Generate ID from CPU + UUID\n") + fmt.Fprintf(os.Stderr, " machineid -all -format 32 All hardware, compact format\n") + fmt.Fprintf(os.Stderr, " machineid -vm -salt \"my-app\" VM-friendly with salt\n") + fmt.Fprintf(os.Stderr, " machineid -cpu -uuid -diagnostics Show collected components\n") + fmt.Fprintf(os.Stderr, " machineid -cpu -uuid -validate Validate an existing ID\n") + fmt.Fprintf(os.Stderr, " machineid -cpu -uuid -json Output as JSON\n") + fmt.Fprintf(os.Stderr, " machineid -version Show version\n") + fmt.Fprintf(os.Stderr, " machineid -version.long Show detailed version\n") + } + + flag.Parse() + + // Handle version flag + if *versionFlag { + if version.Version == "0.0.0" { + if info, ok := debug.ReadBuildInfo(); ok { + fmt.Printf("%s version: %s\n", applicationName, info.Main.Version) + } else { + fmt.Printf("%s version: %s\n", applicationName, version.Version) + } + } else { + fmt.Printf("%s version: %s\n", applicationName, version.Version) + } + + os.Exit(0) + } + + // Handle detailed version flag + if *versionLongFlag { + var sb strings.Builder + + if version.Version == "0.0.0" { + if info, ok := debug.ReadBuildInfo(); ok { + fmt.Fprintf(&sb, "%s version: %s, ", applicationName, info.Main.Version) + fmt.Fprintf(&sb, "Git commit: %s, ", info.Main.Sum) + fmt.Fprintf(&sb, "Go version: %s\n", info.GoVersion) + } else { + fmt.Fprintf(&sb, "%s version: %s\n", applicationName, version.Version) + fmt.Fprintf(&sb, "Build date: %s, ", version.BuildDate) + fmt.Fprintf(&sb, "Build user: %s, ", version.BuildUser) + fmt.Fprintf(&sb, "Git commit: %s, ", version.GitCommit) + fmt.Fprintf(&sb, "Git branch: %s, ", version.GitBranch) + fmt.Fprintf(&sb, "Go version: %s\n", version.GoVersion) + } + } else { + fmt.Fprintf(&sb, "%s version: %s, ", applicationName, version.Version) + fmt.Fprintf(&sb, "Build date: %s, ", version.BuildDate) + fmt.Fprintf(&sb, "Build user: %s, ", version.BuildUser) + fmt.Fprintf(&sb, "Git commit: %s, ", version.GitCommit) + fmt.Fprintf(&sb, "Git branch: %s, ", version.GitBranch) + fmt.Fprintf(&sb, "Go version: %s\n", version.GoVersion) + } + + fmt.Print(sb.String()) + os.Exit(0) + } + + formatMode, err := parseFormatMode(*format) + if err != nil { + slog.Error("invalid format", "error", err) + flag.Usage() + os.Exit(1) + } + + // Build provider + provider := machineid.New().WithFormat(formatMode) + + if *salt != "" { + provider.WithSalt(*salt) + } + + switch { + case *vm: + provider.VMFriendly() + case *all: + provider.WithCPU().WithMotherboard().WithSystemUUID().WithMAC().WithDisk() + default: + if !*cpu && !*motherboard && !*uuid && !*mac && !*disk { + // Default: CPU + Motherboard + System UUID + provider.WithCPU().WithMotherboard().WithSystemUUID() + } else { + if *cpu { + provider.WithCPU() + } + if *motherboard { + provider.WithMotherboard() + } + if *uuid { + provider.WithSystemUUID() + } + if *mac { + provider.WithMAC() + } + if *disk { + provider.WithDisk() + } + } + } + + // Generate machine ID + ctx := context.Background() + + id, err := provider.ID(ctx) + if err != nil { + slog.Error("failed to generate machine ID", "error", err) + os.Exit(1) + } + + // Validate mode + if *validate != "" { + handleValidate(ctx, provider, *validate, *jsonOutput) + return + } + + // Output + if *jsonOutput { + output := map[string]any{ + "id": id, + "format": *format, + "length": len(id), + } + if *diagnostics { + output["diagnostics"] = formatDiagnostics(provider) + } + printJSON(output) + return + } + + fmt.Println(id) + + if *diagnostics { + printDiagnostics(provider) + } +} + +func parseFormatMode(format int) (machineid.FormatMode, error) { + switch format { + case 32: + return machineid.Format32, nil + case 64: + return machineid.Format64, nil + case 128: + return machineid.Format128, nil + case 256: + return machineid.Format256, nil + default: + return 0, fmt.Errorf("unsupported format %d; valid values are 32, 64, 128, 256", format) + } +} + +func handleValidate(ctx context.Context, provider *machineid.Provider, expectedID string, jsonOut bool) { + valid, err := provider.Validate(ctx, expectedID) + if err != nil { + slog.Error("validation failed", "error", err) + os.Exit(1) + } + + if jsonOut { + printJSON(map[string]any{ + "valid": valid, + "expectedID": expectedID, + }) + if !valid { + os.Exit(1) + } + return + } + + if valid { + fmt.Println("valid: machine ID matches") + } else { + fmt.Println("invalid: machine ID does not match") + os.Exit(1) + } +} + +func printDiagnostics(provider *machineid.Provider) { + diag := provider.Diagnostics() + if diag == nil { + fmt.Fprintln(os.Stderr, "no diagnostic information available") + return + } + + fmt.Fprintln(os.Stderr, "\nDiagnostics:") + if len(diag.Collected) > 0 { + fmt.Fprintf(os.Stderr, " Collected: %s\n", strings.Join(diag.Collected, ", ")) + } + if len(diag.Errors) > 0 { + fmt.Fprintln(os.Stderr, " Errors:") + for component, err := range diag.Errors { + fmt.Fprintf(os.Stderr, " %s: %v\n", component, err) + } + } +} + +func formatDiagnostics(provider *machineid.Provider) map[string]any { + diag := provider.Diagnostics() + if diag == nil { + return nil + } + + result := map[string]any{ + "collected": diag.Collected, + } + + if len(diag.Errors) > 0 { + errors := make(map[string]string, len(diag.Errors)) + for component, err := range diag.Errors { + errors[component] = err.Error() + } + result["errors"] = errors + } + + return result +} + +func printJSON(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + slog.Error("failed to encode JSON", "error", err) + os.Exit(1) + } +} diff --git a/darwin.go b/darwin.go new file mode 100644 index 0000000..c129c4f --- /dev/null +++ b/darwin.go @@ -0,0 +1,274 @@ +//go:build darwin + +package machineid + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "regexp" + "strings" +) + +// Compiled regexes for ioreg output parsing. +var ( + ioregUUIDRe = regexp.MustCompile(`"IOPlatformUUID"\s*=\s*"([^"]+)"`) + ioregSerialRe = regexp.MustCompile(`"IOPlatformSerialNumber"\s*=\s*"([^"]+)"`) +) + +// spHardwareDataType represents the JSON output of `system_profiler SPHardwareDataType -json`. +type spHardwareDataType struct { + SPHardwareDataType []spHardwareEntry `json:"SPHardwareDataType"` +} + +type spHardwareEntry struct { + PlatformUUID string `json:"platform_UUID"` + SerialNumber string `json:"serial_number"` + ChipType string `json:"chip_type"` + ModelName string `json:"machine_name"` + MachineModel string `json:"machine_model"` +} + +// spStorageDataType represents the JSON output of `system_profiler SPStorageDataType -json`. +type spStorageDataType struct { + SPStorageDataType []spStorageEntry `json:"SPStorageDataType"` +} + +type spStorageEntry struct { + Name string `json:"_name"` + BSDName string `json:"bsd_name"` + PhysicalDrive spPhysicalDrive `json:"physical_drive"` + VolumeUUID string `json:"volume_uuid"` +} + +type spPhysicalDrive struct { + DeviceName string `json:"device_name"` + IsInternal string `json:"is_internal_disk"` + MediaName string `json:"media_name"` + MediumType string `json:"medium_type"` + Protocol string `json:"protocol"` + SmartStatus string `json:"smart_status"` +} + +// collectIdentifiers gathers macOS-specific hardware identifiers based on provider config. +func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) ([]string, error) { + var identifiers []string + logger := p.logger + + if p.includeSystemUUID { + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return macOSHardwareUUID(ctx, p.commandExecutor, logger) + }, "uuid:", diag, ComponentSystemUUID, logger) + } + + if p.includeMotherboard { + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return macOSSerialNumber(ctx, p.commandExecutor, logger) + }, "serial:", diag, ComponentMotherboard, logger) + } + + if p.includeCPU { + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return macOSCPUInfo(ctx, p.commandExecutor, logger) + }, "cpu:", diag, ComponentCPU, logger) + } + + if p.includeMAC { + identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { + return collectMACAddresses(logger) + }, "mac:", diag, ComponentMAC, logger) + } + + if p.includeDisk { + identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { + return macOSDiskInfo(ctx, p.commandExecutor, logger) + }, "disk:", diag, ComponentDisk, logger) + } + + return identifiers, nil +} + +// macOSHardwareUUID retrieves hardware UUID using system_profiler with JSON parsing. +func macOSHardwareUUID(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + output, err := executeCommand(ctx, executor, logger, "system_profiler", "SPHardwareDataType", "-json") + if err == nil { + uuid, parseErr := extractHardwareField(output, func(e spHardwareEntry) string { + return e.PlatformUUID + }) + if parseErr == nil { + return uuid, nil + } + } + + // Fallback to ioreg + if logger != nil { + logger.Info("falling back to ioreg for hardware UUID") + } + + return macOSHardwareUUIDViaIOReg(ctx, executor, logger) +} + +// macOSHardwareUUIDViaIOReg retrieves hardware UUID using ioreg as fallback. +func macOSHardwareUUIDViaIOReg(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + output, err := executeCommand(ctx, executor, logger, "ioreg", "-d2", "-c", "IOPlatformExpertDevice") + if err != nil { + return "", fmt.Errorf("failed to get hardware UUID: %w", err) + } + + match := ioregUUIDRe.FindStringSubmatch(output) + if len(match) > 1 { + return match[1], nil + } + + return "", errors.New("hardware UUID not found in ioreg output") +} + +// macOSSerialNumber retrieves system serial number. +func macOSSerialNumber(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + output, err := executeCommand(ctx, executor, logger, "system_profiler", "SPHardwareDataType", "-json") + if err == nil { + serial, parseErr := extractHardwareField(output, func(e spHardwareEntry) string { + return e.SerialNumber + }) + if parseErr == nil { + return serial, nil + } + } + + // Fallback to ioreg + if logger != nil { + logger.Info("falling back to ioreg for serial number") + } + + return macOSSerialNumberViaIOReg(ctx, executor, logger) +} + +// macOSSerialNumberViaIOReg retrieves serial number using ioreg as fallback. +func macOSSerialNumberViaIOReg(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + output, err := executeCommand(ctx, executor, logger, "ioreg", "-d2", "-c", "IOPlatformExpertDevice") + if err != nil { + return "", fmt.Errorf("failed to get serial number: %w", err) + } + + match := ioregSerialRe.FindStringSubmatch(output) + if len(match) > 1 { + return match[1], nil + } + + return "", errors.New("serial number not found in ioreg output") +} + +// macOSCPUInfo retrieves CPU information. +// Uses sysctl as primary source (consistent with existing machine IDs). +// On Intel: returns brand_string:features. +// On Apple Silicon: sysctl returns brand_string with empty features, +// producing "ChipType:" β€” this trailing colon is preserved for backward +// compatibility with existing license activations. +// Falls back to system_profiler chip_type only if sysctl fails entirely. +func macOSCPUInfo(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + // Primary: sysctl (backward compatible) + output, err := executeCommand(ctx, executor, logger, "sysctl", "-n", "machdep.cpu.brand_string") + if err == nil { + cpuBrand := strings.TrimSpace(output) + if cpuBrand != "" { + // Get CPU features (populated on Intel, empty on Apple Silicon) + featOutput, featErr := executeCommand(ctx, executor, logger, "sysctl", "-n", "machdep.cpu.features") + if featErr == nil { + features := strings.TrimSpace(featOutput) + + return fmt.Sprintf("%s:%s", cpuBrand, features), nil + } + + return cpuBrand, nil + } + } + + // Fallback: system_profiler for Apple Silicon chip type + if logger != nil { + logger.Info("falling back to system_profiler for CPU info") + } + + profilerOutput, profilerErr := executeCommand(ctx, executor, logger, "system_profiler", "SPHardwareDataType", "-json") + if profilerErr == nil { + var hw spHardwareDataType + if jsonErr := json.Unmarshal([]byte(profilerOutput), &hw); jsonErr == nil && len(hw.SPHardwareDataType) > 0 { + entry := hw.SPHardwareDataType[0] + if entry.ChipType != "" { + return entry.ChipType, nil + } + } + } + + return "", errors.New("failed to get CPU info: all methods failed") +} + +// macOSDiskInfo retrieves internal disk device names for stable machine identification. +// It uses system_profiler with JSON output and filters to internal disks only, +// deduplicating across volumes on the same physical disk. +func macOSDiskInfo(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) { + output, err := executeCommand(ctx, executor, logger, "system_profiler", "SPStorageDataType", "-json") + if err != nil { + return nil, fmt.Errorf("failed to get disk info: %w", err) + } + + return parseStorageJSON(output) +} + +// parseStorageJSON parses system_profiler SPStorageDataType JSON and extracts +// unique internal disk device names. +func parseStorageJSON(jsonOutput string) ([]string, error) { + var storage spStorageDataType + if err := json.Unmarshal([]byte(jsonOutput), &storage); err != nil { + return nil, fmt.Errorf("failed to parse storage JSON: %w", err) + } + + // Use a set to deduplicate β€” multiple volumes can share the same physical disk. + seen := make(map[string]struct{}) + var diskNames []string + + for _, entry := range storage.SPStorageDataType { + name := entry.PhysicalDrive.DeviceName + if name == "" { + continue + } + + // Only include internal disks for stability. + if entry.PhysicalDrive.IsInternal != "yes" { + continue + } + + if _, exists := seen[name]; exists { + continue + } + + seen[name] = struct{}{} + diskNames = append(diskNames, name) + } + + if len(diskNames) == 0 { + return nil, errors.New("no internal disk identifiers found") + } + + return diskNames, nil +} + +// extractHardwareField extracts a field from system_profiler SPHardwareDataType JSON output. +func extractHardwareField(jsonOutput string, fieldFn func(spHardwareEntry) string) (string, error) { + var hw spHardwareDataType + if err := json.Unmarshal([]byte(jsonOutput), &hw); err != nil { + return "", fmt.Errorf("failed to parse hardware JSON: %w", err) + } + + if len(hw.SPHardwareDataType) == 0 { + return "", errors.New("no hardware data found in JSON output") + } + + value := fieldFn(hw.SPHardwareDataType[0]) + if value == "" { + return "", errors.New("field is empty in hardware data") + } + + return value, nil +} diff --git a/darwin_test.go b/darwin_test.go new file mode 100644 index 0000000..d4a3b1b --- /dev/null +++ b/darwin_test.go @@ -0,0 +1,344 @@ +//go:build darwin + +package machineid + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// TestExtractHardwareFieldValid tests successful field extraction from JSON. +func TestExtractHardwareFieldValid(t *testing.T) { + jsonOutput := `{ + "SPHardwareDataType": [{ + "platform_UUID": "12345-67890", + "serial_number": "C02TEST123", + "chip_type": "Apple M1 Pro", + "machine_model": "MacBookPro18,3" + }] + }` + result, err := extractHardwareField(jsonOutput, func(e spHardwareEntry) string { + return e.PlatformUUID + }) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result != "12345-67890" { + t.Errorf("Expected '12345-67890', got '%s'", result) + } +} + +// TestExtractHardwareFieldEmpty tests extraction when field is empty. +func TestExtractHardwareFieldEmpty(t *testing.T) { + jsonOutput := `{ + "SPHardwareDataType": [{ + "platform_UUID": "", + "serial_number": "C02TEST123" + }] + }` + _, err := extractHardwareField(jsonOutput, func(e spHardwareEntry) string { + return e.PlatformUUID + }) + if err == nil { + t.Error("Expected error when field is empty") + } +} + +// TestExtractHardwareFieldNoData tests extraction when no data entries exist. +func TestExtractHardwareFieldNoData(t *testing.T) { + jsonOutput := `{"SPHardwareDataType": []}` + _, err := extractHardwareField(jsonOutput, func(e spHardwareEntry) string { + return e.PlatformUUID + }) + if err == nil { + t.Error("Expected error when no data entries") + } +} + +// TestExtractHardwareFieldInvalidJSON tests extraction with invalid JSON. +func TestExtractHardwareFieldInvalidJSON(t *testing.T) { + _, err := extractHardwareField("not json", func(e spHardwareEntry) string { + return e.PlatformUUID + }) + if err == nil { + t.Error("Expected error for invalid JSON") + } +} + +// TestMacOSHardwareUUIDViaIORegNotFound tests ioreg fallback when UUID not in output. +func TestMacOSHardwareUUIDViaIORegNotFound(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("ioreg", "some output without UUID") + + _, err := macOSHardwareUUIDViaIOReg(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when UUID not found in ioreg output") + } +} + +// TestMacOSHardwareUUIDViaIORegError tests ioreg command error. +func TestMacOSHardwareUUIDViaIORegError(t *testing.T) { + mock := newMockExecutor() + mock.setError("ioreg", fmt.Errorf("command failed")) + + _, err := macOSHardwareUUIDViaIOReg(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when ioreg command fails") + } +} + +// TestMacOSHardwareUUIDViaIORegSuccess tests successful UUID extraction. +func TestMacOSHardwareUUIDViaIORegSuccess(t *testing.T) { + mock := newMockExecutor() + ioregOutput := ` + +-o IOPlatformExpertDevice + | { + | "IOPlatformUUID" = "ABCD-1234-EFGH-5678" + | } + ` + mock.setOutput("ioreg", ioregOutput) + + result, err := macOSHardwareUUIDViaIOReg(context.Background(), mock, nil) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result != "ABCD-1234-EFGH-5678" { + t.Errorf("Expected 'ABCD-1234-EFGH-5678', got '%s'", result) + } +} + +// TestMacOSSerialNumberViaIORegError tests ioreg error. +func TestMacOSSerialNumberViaIORegError(t *testing.T) { + mock := newMockExecutor() + mock.setError("ioreg", fmt.Errorf("command failed")) + + _, err := macOSSerialNumberViaIOReg(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when ioreg command fails") + } +} + +// TestMacOSSerialNumberViaIORegNotFound tests when serial not in output. +func TestMacOSSerialNumberViaIORegNotFound(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("ioreg", "output without serial") + + _, err := macOSSerialNumberViaIOReg(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when serial not found") + } +} + +// TestMacOSSerialNumberViaIORegSuccess tests successful extraction. +func TestMacOSSerialNumberViaIORegSuccess(t *testing.T) { + mock := newMockExecutor() + ioregOutput := ` + "IOPlatformSerialNumber" = "C02TEST123" + ` + mock.setOutput("ioreg", ioregOutput) + + result, err := macOSSerialNumberViaIOReg(context.Background(), mock, nil) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result != "C02TEST123" { + t.Errorf("Expected 'C02TEST123', got '%s'", result) + } +} + +// TestMacOSSerialNumberFallback tests fallback to ioreg. +func TestMacOSSerialNumberFallback(t *testing.T) { + mock := newMockExecutor() + mock.setError("system_profiler", fmt.Errorf("system_profiler failed")) + mock.setOutput("ioreg", `"IOPlatformSerialNumber" = "C02FALLBACK"`) + + result, err := macOSSerialNumber(context.Background(), mock, nil) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result != "C02FALLBACK" { + t.Errorf("Expected 'C02FALLBACK', got '%s'", result) + } +} + +// TestMacOSCPUInfoError tests CPU info error handling. +func TestMacOSCPUInfoError(t *testing.T) { + mock := newMockExecutor() + mock.setError("sysctl", fmt.Errorf("command failed")) + mock.setError("system_profiler", fmt.Errorf("command failed")) + + _, err := macOSCPUInfo(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when all CPU info commands fail") + } +} + +// TestMacOSCPUInfoAppleSiliconViaSysctl tests Apple Silicon CPU info using sysctl +// (primary path). On Apple Silicon, sysctl -n machdep.cpu.brand_string returns +// the chip name, and machdep.cpu.features returns empty, producing "ChipType:". +func TestMacOSCPUInfoAppleSiliconViaSysctl(t *testing.T) { + mock := newMockExecutor() + // sysctl returns "Apple M1 Pro" as brand_string, empty features (Apple Silicon behavior) + mock.setOutput("sysctl", "Apple M1 Pro") + + result, err := macOSCPUInfo(context.Background(), mock, nil) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + // NOTE: On Apple Silicon, sysctl -n machdep.cpu.features succeeds with empty output. + // The mock returns the same output for both sysctl calls (brand_string and features) + // because it keys by command name only. In production, features is empty, producing + // "Apple M1 Pro:" (trailing colon). The mock produces "Apple M1 Pro:Apple M1 Pro" + // which still exercises the code path correctly. + if !strings.HasPrefix(result, "Apple M1 Pro") { + t.Errorf("Expected result to start with 'Apple M1 Pro', got '%s'", result) + } +} + +// TestMacOSCPUInfoFallbackToProfiler tests CPU info falls back to system_profiler +// when sysctl is not available. +func TestMacOSCPUInfoFallbackToProfiler(t *testing.T) { + mock := newMockExecutor() + // sysctl not configured β†’ error β†’ falls through to system_profiler + mock.setOutput("system_profiler", `{ + "SPHardwareDataType": [{ + "chip_type": "Apple M1 Pro", + "machine_model": "MacBookPro18,3", + "platform_UUID": "SOME-UUID", + "serial_number": "SERIAL123" + }] + }`) + + result, err := macOSCPUInfo(context.Background(), mock, nil) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result != "Apple M1 Pro" { + t.Errorf("Expected 'Apple M1 Pro', got '%s'", result) + } +} + +// TestMacOSCPUInfoAllFail tests that CPU info returns error when all methods fail. +func TestMacOSCPUInfoAllFail(t *testing.T) { + mock := newMockExecutor() + // Neither sysctl nor system_profiler configured β†’ all fail + mock.setError("sysctl", fmt.Errorf("command not found")) + mock.setError("system_profiler", fmt.Errorf("command not found")) + + _, err := macOSCPUInfo(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when all CPU methods fail") + } +} + +// TestParseStorageJSON tests proper JSON parsing of storage data. +func TestParseStorageJSON(t *testing.T) { + jsonOutput := `{ + "SPStorageDataType": [ + { + "_name": "Macintosh HD - Data", + "bsd_name": "disk3s1", + "physical_drive": { + "device_name": "APPLE SSD AP1024R", + "is_internal_disk": "yes", + "medium_type": "ssd" + } + }, + { + "_name": "Macintosh HD", + "bsd_name": "disk3s3s1", + "physical_drive": { + "device_name": "APPLE SSD AP1024R", + "is_internal_disk": "yes", + "medium_type": "ssd" + } + }, + { + "_name": "External Drive", + "bsd_name": "disk8s2", + "physical_drive": { + "device_name": "SA400S37960G", + "is_internal_disk": "no", + "medium_type": "ssd" + } + } + ] + }` + + result, err := parseStorageJSON(jsonOutput) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Should only have 1 entry: APPLE SSD AP1024R (deduplicated, internal only) + if len(result) != 1 { + t.Errorf("Expected 1 disk entry (deduplicated internal), got %d: %v", len(result), result) + } + if result[0] != "APPLE SSD AP1024R" { + t.Errorf("Expected 'APPLE SSD AP1024R', got '%s'", result[0]) + } +} + +// TestParseStorageJSONNoInternal tests when no internal disks are found. +func TestParseStorageJSONNoInternal(t *testing.T) { + jsonOutput := `{ + "SPStorageDataType": [ + { + "_name": "External", + "physical_drive": { + "device_name": "External SSD", + "is_internal_disk": "no" + } + } + ] + }` + + _, err := parseStorageJSON(jsonOutput) + if err == nil { + t.Error("Expected error when no internal disks found") + } +} + +// TestParseStorageJSONInvalid tests invalid JSON. +func TestParseStorageJSONInvalid(t *testing.T) { + _, err := parseStorageJSON("not json") + if err == nil { + t.Error("Expected error for invalid JSON") + } +} + +// TestMacOSDiskInfoError tests disk info when system_profiler fails. +func TestMacOSDiskInfoError(t *testing.T) { + mock := newMockExecutor() + mock.setError("system_profiler", fmt.Errorf("command failed")) + + _, err := macOSDiskInfo(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when system_profiler fails") + } +} + +// TestMacOSDiskInfoSuccess tests successful disk info via JSON. +func TestMacOSDiskInfoSuccess(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("system_profiler", `{ + "SPStorageDataType": [ + { + "_name": "Macintosh HD", + "physical_drive": { + "device_name": "APPLE SSD AP1024R", + "is_internal_disk": "yes" + } + } + ] + }`) + + result, err := macOSDiskInfo(context.Background(), mock, nil) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if len(result) != 1 { + t.Errorf("Expected 1 disk entry, got %d", len(result)) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..2c46d59 --- /dev/null +++ b/doc.go @@ -0,0 +1,138 @@ +// Package machineid generates unique, deterministic machine identifiers derived +// from hardware characteristics. The generated IDs are stable across reboots +// but sensitive to hardware changes, making them suitable for software licensing, +// device fingerprinting, and telemetry correlation. +// +// # Zero Dependencies +// +// This package relies exclusively on the Go standard library and OS-level +// commands. There are no third-party dependencies. +// +// # Overview +// +// A [Provider] collects hardware signals (CPU, motherboard serial, system UUID, +// MAC addresses, disk serials), sorts and concatenates them, then produces a +// SHA-256 based hexadecimal fingerprint. The result length is always a power of +// two: 32, 64, 128, or 256 characters, controlled by [FormatMode]. +// +// # Quick Start +// +// id, err := machineid.New(). +// WithCPU(). +// WithSystemUUID(). +// ID(ctx) +// +// # Configuring Hardware Sources +// +// Enable individual hardware components via the With* methods: +// +// - [Provider.WithCPU] β€” processor identifier and feature flags +// - [Provider.WithMotherboard] β€” motherboard / baseboard serial number +// - [Provider.WithSystemUUID] β€” BIOS / UEFI system UUID +// - [Provider.WithMAC] β€” MAC addresses of physical network interfaces +// - [Provider.WithDisk] β€” serial numbers of internal disks +// +// Or use [Provider.VMFriendly] to select a minimal, virtual-machine-safe +// subset (CPU + System UUID). +// +// # Output Formats +// +// Set the output length with [Provider.WithFormat]: +// +// - [Format32] β€” 32 hex characters (128 bits, truncated SHA-256) +// - [Format64] β€” 64 hex characters (256 bits, full SHA-256, default) +// - [Format128] β€” 128 hex characters (512 bits, double SHA-256) +// - [Format256] β€” 256 hex characters (1024 bits, quadruple SHA-256) +// +// All formats produce pure hexadecimal strings without dashes. +// +// # Salt +// +// [Provider.WithSalt] mixes an application-specific string into the hash so +// that two applications on the same machine produce different IDs: +// +// id, _ := machineid.New(). +// WithCPU(). +// WithSystemUUID(). +// WithSalt("my-app-v1"). +// ID(ctx) +// +// # Validation +// +// [Provider.Validate] regenerates the ID and compares it to a previously +// stored value: +// +// valid, err := provider.Validate(ctx, storedID) +// +// # Diagnostics +// +// After calling [Provider.ID], call [Provider.Diagnostics] to inspect which +// components were collected and which encountered errors: +// +// diag := provider.Diagnostics() +// fmt.Println("Collected:", diag.Collected) +// fmt.Println("Errors:", diag.Errors) +// +// # Logging +// +// [Provider.WithLogger] accepts a [*log/slog.Logger] for optional observability. +// When set, the provider logs component collection results, fallback paths, +// command execution timing, and errors. A nil logger (the default) disables +// all logging with zero overhead. +// +// logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) +// id, err := machineid.New(). +// WithCPU(). +// WithSystemUUID(). +// WithLogger(logger). +// ID(ctx) +// +// Log levels: +// - Info: component collected, fallback triggered, ID generation lifecycle +// - Warn: component failed or returned empty value +// - Debug: command execution details, raw hardware values, timing +// +// # Thread Safety +// +// A [Provider] is safe for concurrent use after configuration is complete. +// The first successful call to [Provider.ID] freezes the configuration and +// caches the result; subsequent calls return the cached value. +// +// # Testing +// +// Inject a custom [CommandExecutor] via [Provider.WithExecutor] to replace +// real system commands with deterministic test doubles: +// +// provider := machineid.New(). +// WithExecutor(myMock). +// WithCPU() +// +// # Platform Support +// +// Supported operating systems: macOS (darwin), Linux, and Windows. Each +// platform uses native tools (system_profiler / ioreg, /sys / lsblk, wmic / +// PowerShell) to collect hardware data. +// +// # Installation +// +// To use machineid as a library in your Go project: +// +// go get github.com/slashdevops/machineid +// +// To install the CLI tool: +// +// go install github.com/slashdevops/machineid/cmd/machineid@latest +// +// Precompiled binaries for macOS, Linux, and Windows are available on the +// [releases page]: https://github.com/slashdevops/machineid/releases +// +// # CLI Tool +// +// A ready-to-use command-line tool is provided in cmd/machineid: +// +// machineid -cpu -uuid +// machineid -all -format 32 -json +// machineid -vm -salt "my-app" -diagnostics +// machineid -version +// machineid -version.long +package machineid diff --git a/example_poweroftwo_test.go b/example_poweroftwo_test.go new file mode 100644 index 0000000..82a3c26 --- /dev/null +++ b/example_poweroftwo_test.go @@ -0,0 +1,87 @@ +package machineid_test + +import ( + "context" + "fmt" + "math" + + "github.com/slashdevops/machineid" +) + +// Example_powerOfTwo demonstrates why power-of-2 lengths are beneficial. +func Example_powerOfTwo() { + // Format32: 32 hex chars = 128 bits = 2^128 possible values + id32, _ := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format32).ID(context.Background()) + fmt.Printf("Format32 (2^5 chars): %d characters\n", len(id32)) + fmt.Printf("Format32 bits: %d (2^%d possible values)\n", len(id32)*4, len(id32)*4) + + // Format64: 64 hex chars = 256 bits = 2^256 possible values (full SHA-256) + id64, _ := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format64).ID(context.Background()) + fmt.Printf("Format64 (2^6 chars): %d characters\n", len(id64)) + fmt.Printf("Format64 bits: %d (2^%d possible values)\n", len(id64)*4, len(id64)*4) + + // Output: + // Format32 (2^5 chars): 32 characters + // Format32 bits: 128 (2^128 possible values) + // Format64 (2^6 chars): 64 characters + // Format64 bits: 256 (2^256 possible values) +} + +// Example_integrity demonstrates that the format maintains integrity without collisions. +func Example_integrity() { + // Generate multiple IDs to show consistency and uniqueness + p1 := machineid.New().WithCPU().WithSystemUUID() + p2 := machineid.New().WithCPU().WithSystemUUID().WithMotherboard() + p3 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app1") + p4 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app2") + + id1, _ := p1.ID(context.Background()) + id2, _ := p2.ID(context.Background()) + id3, _ := p3.ID(context.Background()) + id4, _ := p4.ID(context.Background()) + + // Same configuration always produces same ID + id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background()) + fmt.Printf("Consistency: %v\n", id1 == id1Again) + + // Different configurations produce different IDs + fmt.Printf("Different hardware: %v\n", id1 != id2) + fmt.Printf("Different salts: %v\n", id3 != id4) + + // All IDs are 64 characters (power of 2) + fmt.Printf("All are 64 chars: %v\n", + len(id1) == 64 && len(id2) == 64 && len(id3) == 64 && len(id4) == 64) + + // Output: + // Consistency: true + // Different hardware: true + // Different salts: true + // All are 64 chars: true +} + +// Example_collisionResistance demonstrates the collision resistance of different formats. +func Example_collisionResistance() { + // Calculate collision probability (simplified) + format32Bits := 128.0 // 32 hex chars = 128 bits + format64Bits := 256.0 // 64 hex chars = 256 bits + + // For random IDs, probability of collision after N IDs (birthday paradox): + // P(collision) β‰ˆ N^2 / (2 * 2^bits) + // For no collision with 1 billion IDs: + n := 1e9 // 1 billion IDs + + // Format32 (128 bits) + collisionProb32 := (n * n) / (2 * math.Pow(2, format32Bits)) + fmt.Printf("Format32 collision probability with 1B IDs: %.2e\n", collisionProb32) + + // Format64 (256 bits) - essentially zero + collisionProb64 := (n * n) / (2 * math.Pow(2, format64Bits)) + fmt.Printf("Format64 collision probability with 1B IDs: %.2e\n", collisionProb64) + + fmt.Printf("Format64 is more secure: %v\n", collisionProb64 < collisionProb32) + + // Output: + // Format32 collision probability with 1B IDs: 1.47e-21 + // Format64 collision probability with 1B IDs: 4.32e-60 + // Format64 is more secure: true +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..63feb8e --- /dev/null +++ b/example_test.go @@ -0,0 +1,164 @@ +package machineid_test + +import ( + "context" + "fmt" + + "github.com/slashdevops/machineid" +) + +// ExampleNew demonstrates the simplest way to generate a machine ID. +func ExampleNew() { + provider := machineid.New(). + WithCPU(). + WithSystemUUID() + + id, err := provider.ID(context.Background()) + if err != nil { + fmt.Printf("error: %v\n", err) + return + } + + fmt.Printf("ID length: %d\n", len(id)) + // Output: + // ID length: 64 +} + +// ExampleProvider_WithSalt shows how a salt produces application-specific IDs. +func ExampleProvider_WithSalt() { + ctx := context.Background() + + id1, _ := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithSalt("app-one"). + ID(ctx) + + id2, _ := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithSalt("app-two"). + ID(ctx) + + fmt.Printf("Same length: %v\n", len(id1) == len(id2)) + fmt.Printf("Different IDs: %v\n", id1 != id2) + // Output: + // Same length: true + // Different IDs: true +} + +// ExampleProvider_WithFormat demonstrates the four power-of-two output formats. +func ExampleProvider_WithFormat() { + ctx := context.Background() + base := func(mode machineid.FormatMode) int { + id, err := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(mode). + ID(ctx) + if err != nil { + return -1 + } + return len(id) + } + + fmt.Printf("Format32: %d chars\n", base(machineid.Format32)) + fmt.Printf("Format64: %d chars\n", base(machineid.Format64)) + fmt.Printf("Format128: %d chars\n", base(machineid.Format128)) + fmt.Printf("Format256: %d chars\n", base(machineid.Format256)) + // Output: + // Format32: 32 chars + // Format64: 64 chars + // Format128: 128 chars + // Format256: 256 chars +} + +// ExampleProvider_Validate shows how to check a stored ID against the current machine. +func ExampleProvider_Validate() { + provider := machineid.New(). + WithCPU(). + WithSystemUUID() + + id, _ := provider.ID(context.Background()) + + // Validate the correct ID + valid, _ := provider.Validate(context.Background(), id) + fmt.Printf("Correct ID valid: %v\n", valid) + + // Validate an incorrect ID + valid, _ = provider.Validate(context.Background(), "0000000000000000000000000000000000000000000000000000000000000000") + fmt.Printf("Wrong ID valid: %v\n", valid) + + // Output: + // Correct ID valid: true + // Wrong ID valid: false +} + +// ExampleProvider_Diagnostics demonstrates inspecting which hardware components +// were successfully collected. +func ExampleProvider_Diagnostics() { + provider := machineid.New(). + WithCPU(). + WithSystemUUID() + + _, _ = provider.ID(context.Background()) + + diag := provider.Diagnostics() + if diag == nil { + fmt.Println("no diagnostics") + return + } + + fmt.Printf("Components collected: %d\n", len(diag.Collected)) + fmt.Printf("Has collected data: %v\n", len(diag.Collected) > 0) + // Output: + // Components collected: 2 + // Has collected data: true +} + +// ExampleProvider_VMFriendly_preset demonstrates the VM-friendly preset. +func ExampleProvider_VMFriendly_preset() { + id, err := machineid.New(). + VMFriendly(). + ID(context.Background()) + if err != nil { + fmt.Printf("error: %v\n", err) + return + } + + fmt.Printf("VM-friendly ID length: %d\n", len(id)) + // Output: + // VM-friendly ID length: 64 +} + +// ExampleProvider_ID_allComponents shows using every available hardware source. +func ExampleProvider_ID_allComponents() { + provider := machineid.New(). + WithCPU(). + WithMotherboard(). + WithSystemUUID(). + WithMAC(). + WithDisk(). + WithSalt("full-example") + + id, err := provider.ID(context.Background()) + if err != nil { + fmt.Printf("error: %v\n", err) + return + } + + fmt.Printf("Full ID length: %d\n", len(id)) + fmt.Printf("Is hex: %v\n", isAllHex(id)) + // Output: + // Full ID length: 64 + // Is hex: true +} + +func isAllHex(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return len(s) > 0 +} diff --git a/executor.go b/executor.go new file mode 100644 index 0000000..7cade7a --- /dev/null +++ b/executor.go @@ -0,0 +1,63 @@ +package machineid + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "strings" + "time" +) + +// defaultCommandExecutor implements CommandExecutor using actual system command execution. +type defaultCommandExecutor struct { + Timeout time.Duration +} + +// Execute runs a system command with a timeout and returns the output. +// It uses context.WithTimeout to prevent commands from hanging indefinitely. +func (e *defaultCommandExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) { + timeout := e.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, name, args...) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("command %q failed: %w", name, err) + } + + return strings.TrimSpace(string(output)), nil +} + +// executeCommand is a convenience wrapper that calls Execute with the given context. +// This function is used by platform-specific collectors that need the Provider's executor. +func executeCommand(ctx context.Context, executor CommandExecutor, logger *slog.Logger, name string, args ...string) (string, error) { + if executor == nil { + executor = &defaultCommandExecutor{ + Timeout: defaultTimeout, + } + } + + if logger != nil { + logger.Debug("executing command", "command", name, "args", args) + } + + start := time.Now() + result, err := executor.Execute(ctx, name, args...) + duration := time.Since(start) + + if logger != nil { + if err != nil { + logger.Debug("command failed", "command", name, "duration", duration, "error", err) + } else { + logger.Debug("command completed", "command", name, "duration", duration) + } + } + + return result, err +} diff --git a/executor_test.go b/executor_test.go new file mode 100644 index 0000000..522d9ab --- /dev/null +++ b/executor_test.go @@ -0,0 +1,77 @@ +package machineid + +import ( + "context" + "fmt" + "testing" + "time" +) + +// mockExecutor is a test double that implements CommandExecutor for testing. +type mockExecutor struct { + // outputs maps command name to expected output + outputs map[string]string + // errors maps command name to expected error + errors map[string]error + // callCount tracks how many times each command was called + callCount map[string]int +} + +// newMockExecutor creates a new mock executor for testing. +func newMockExecutor() *mockExecutor { + return &mockExecutor{ + outputs: make(map[string]string), + errors: make(map[string]error), + callCount: make(map[string]int), + } +} + +// Execute implements CommandExecutor interface. +func (m *mockExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) { + m.callCount[name]++ + + if err, exists := m.errors[name]; exists { + return "", err + } + + if output, exists := m.outputs[name]; exists { + return output, nil + } + + return "", fmt.Errorf("command %q not configured in mock", name) +} + +// setOutput configures the mock to return the given output for a command. +func (m *mockExecutor) setOutput(command, output string) { + m.outputs[command] = output +} + +// setError configures the mock to return an error for a command. +func (m *mockExecutor) setError(command string, err error) { + m.errors[command] = err +} + +// TestExecuteTimeout tests that command execution respects timeout. +func TestExecuteTimeout(t *testing.T) { + executor := &defaultCommandExecutor{} + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + time.Sleep(2 * time.Millisecond) // Ensure timeout expires + + _, err := executor.Execute(ctx, "echo", "test") + if err == nil { + t.Error("Expected timeout error but got none") + } +} + +// TestExecuteCommandWithNilExecutor tests executeCommand with nil executor. +func TestExecuteCommandWithNilExecutor(t *testing.T) { + // This should use the default realExecutor + _, err := executeCommand(context.Background(), nil, nil, "echo", "test") + // We expect this to work or fail gracefully + if err != nil { + // That's fine, we just want to ensure no panic + t.Logf("Command execution with nil executor: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..16b5e24 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/slashdevops/machineid + +go 1.25.0 diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..bc78c21 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,37 @@ +// Package version provides build-time metadata for the CLI application. +// +// All variables have sensible defaults and can be overridden at build time +// using -ldflags: +// +// go build -ldflags "\ +// -X 'github.com/slashdevops/machineid/internal/version.Version=1.0.0' \ +// -X 'github.com/slashdevops/machineid/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" +package version + +import "runtime" + +var ( + // Version is the current version of the application + Version = "0.0.0" + + // BuildDate is the date the application was built + BuildDate = "1970-01-01T00:00:00Z" + + // GitCommit is the commit hash the application was built from + GitCommit = "" + + // GitBranch is the branch the application was built from + GitBranch = "" + + // BuildUser is the user that built the application + BuildUser = "" + + // GoVersion is the version of Go used to build the application + GoVersion = runtime.Version() + + // GoVersionArch is the architecture of Go used to build the application + GoVersionArch = runtime.GOARCH + + // GoVersionOS is the operating system of Go used to build the application + GoVersionOS = runtime.GOOS +) diff --git a/linux.go b/linux.go new file mode 100644 index 0000000..e3e9cab --- /dev/null +++ b/linux.go @@ -0,0 +1,235 @@ +//go:build linux + +package machineid + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" +) + +// collectIdentifiers gathers Linux-specific hardware identifiers based on provider config. +func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) ([]string, error) { + var identifiers []string + logger := p.logger + + if p.includeCPU { + identifiers = appendIdentifierIfValid(identifiers, linuxCPUID, "cpu:", diag, ComponentCPU, logger) + } + + if p.includeSystemUUID { + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return linuxSystemUUID(logger) + }, "uuid:", diag, ComponentSystemUUID, logger) + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return linuxMachineID(logger) + }, "machine:", diag, ComponentMachineID, logger) + } + + if p.includeMotherboard { + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return linuxMotherboardSerial(logger) + }, "mb:", diag, ComponentMotherboard, logger) + } + + if p.includeMAC { + identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { + return collectMACAddresses(logger) + }, "mac:", diag, ComponentMAC, logger) + } + + if p.includeDisk { + identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { + return linuxDiskSerials(ctx, p.commandExecutor, logger) + }, "disk:", diag, ComponentDisk, logger) + } + + return identifiers, nil +} + +// linuxCPUID retrieves CPU information from /proc/cpuinfo +func linuxCPUID() (string, error) { + data, err := os.ReadFile("/proc/cpuinfo") + if err != nil { + return "", err + } + + return parseCPUInfo(string(data)), nil +} + +// parseCPUInfo extracts CPU information from /proc/cpuinfo content +func parseCPUInfo(content string) string { + lines := strings.Split(content, "\n") + var processor, vendorID, modelName, flags string + + for _, line := range lines { + line = strings.TrimSpace(line) + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + switch { + case strings.HasPrefix(line, "processor"): + processor = strings.TrimSpace(parts[1]) + case strings.HasPrefix(line, "vendor_id"): + vendorID = strings.TrimSpace(parts[1]) + case strings.HasPrefix(line, "model name"): + modelName = strings.TrimSpace(parts[1]) + case strings.HasPrefix(line, "flags"): + flags = strings.TrimSpace(parts[1]) + } + } + + // Combine CPU information for unique identifier + return fmt.Sprintf("%s:%s:%s:%s", processor, vendorID, modelName, flags) +} + +// linuxSystemUUID retrieves system UUID from DMI +func linuxSystemUUID(logger *slog.Logger) (string, error) { + // Try multiple locations for system UUID + locations := []string{ + "/sys/class/dmi/id/product_uuid", + "/sys/devices/virtual/dmi/id/product_uuid", + } + + return readFirstValidFromLocations(locations, isValidUUID, logger) +} + +// linuxMotherboardSerial retrieves motherboard serial number from DMI +func linuxMotherboardSerial(logger *slog.Logger) (string, error) { + locations := []string{ + "/sys/class/dmi/id/board_serial", + "/sys/devices/virtual/dmi/id/board_serial", + } + + return readFirstValidFromLocations(locations, isValidSerial, logger) +} + +// linuxMachineID retrieves systemd machine ID +func linuxMachineID(logger *slog.Logger) (string, error) { + locations := []string{ + "/etc/machine-id", + "/var/lib/dbus/machine-id", + } + + return readFirstValidFromLocations(locations, isNonEmpty, logger) +} + +// readFirstValidFromLocations reads from multiple locations until valid value found +func readFirstValidFromLocations(locations []string, validator func(string) bool, logger *slog.Logger) (string, error) { + for _, location := range locations { + data, err := os.ReadFile(location) + if err == nil { + value := strings.TrimSpace(string(data)) + if validator(value) { + if logger != nil { + logger.Debug("read value from file", "path", location) + } + + return value, nil + } + + if logger != nil { + logger.Debug("file value failed validation", "path", location) + } + } else if logger != nil { + logger.Debug("failed to read file", "path", location, "error", err) + } + } + + return "", errors.New("valid value not found in any location") +} + +// isValidUUID checks if UUID is valid (not empty or null) +func isValidUUID(uuid string) bool { + return uuid != "" && uuid != "00000000-0000-0000-0000-000000000000" +} + +// isValidSerial checks if serial is valid (not empty or placeholder) +func isValidSerial(serial string) bool { + return serial != "" && serial != biosFirmwareMessage +} + +// isNonEmpty checks if value is not empty +func isNonEmpty(value string) bool { + return value != "" +} + +// linuxDiskSerials retrieves disk serial numbers using various methods. +// Results are deduplicated across sources to prevent the same serial +// from appearing multiple times. +func linuxDiskSerials(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) { + seen := make(map[string]struct{}) + var serials []string + + // Try using lsblk command first + if lsblkSerials, err := linuxDiskSerialsLSBLK(ctx, executor, logger); err == nil { + for _, s := range lsblkSerials { + if _, exists := seen[s]; !exists { + seen[s] = struct{}{} + serials = append(serials, s) + } + } + } + + // Try reading from /sys/block + if sysSerials, err := linuxDiskSerialsSys(); err == nil { + for _, s := range sysSerials { + if _, exists := seen[s]; !exists { + seen[s] = struct{}{} + serials = append(serials, s) + } + } + } + + return serials, nil +} + +// linuxDiskSerialsLSBLK retrieves disk serials using lsblk command. +func linuxDiskSerialsLSBLK(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) { + output, err := executeCommand(ctx, executor, logger, "lsblk", "-d", "-n", "-o", "SERIAL") + if err != nil { + return nil, fmt.Errorf("failed to get disk serials: %w", err) + } + + var serials []string + lines := strings.SplitSeq(output, "\n") + for line := range lines { + serial := strings.TrimSpace(line) + if serial != "" { + serials = append(serials, serial) + } + } + + return serials, nil +} + +// linuxDiskSerialsSys retrieves disk serials from /sys/block +func linuxDiskSerialsSys() ([]string, error) { + var serials []string + + blockDir := "/sys/block" + entries, err := os.ReadDir(blockDir) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() && !strings.HasPrefix(entry.Name(), "loop") { + serialFile := filepath.Join(blockDir, entry.Name(), "device", "serial") + if data, err := os.ReadFile(serialFile); err == nil { + serial := strings.TrimSpace(string(data)) + if serial != "" { + serials = append(serials, serial) + } + } + } + } + + return serials, nil +} diff --git a/machineid b/machineid new file mode 100755 index 0000000..398b6df Binary files /dev/null and b/machineid differ diff --git a/machineid.go b/machineid.go new file mode 100644 index 0000000..d0532e3 --- /dev/null +++ b/machineid.go @@ -0,0 +1,427 @@ +package machineid + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "runtime" + "sort" + "strings" + "sync" + "time" +) + +// Sentinel errors returned by [Provider.ID]. +var ( + // ErrNoIdentifiers is returned when no hardware identifiers could be + // collected with the current configuration. + ErrNoIdentifiers = errors.New("no hardware identifiers found with current configuration") + + // ErrEmptyValue is returned in [DiagnosticInfo.Errors] when a hardware + // component returned an empty value. + ErrEmptyValue = errors.New("empty value returned") + + // ErrNoValues is returned in [DiagnosticInfo.Errors] when a hardware + // component returned no values. + ErrNoValues = errors.New("no values found") +) + +// FormatMode defines the output format and length of the machine ID. +type FormatMode int + +const ( + // Format64 outputs 64 hex characters (2^6), default SHA-256 output without dashes + Format64 FormatMode = iota + // Format32 outputs 32 hex characters (2^5), truncated SHA-256 + Format32 + // Format128 outputs 128 hex characters (2^7), double SHA-256 + Format128 + // Format256 outputs 256 hex characters (2^8), quadruple SHA-256 + Format256 +) + +// Component names used as keys in DiagnosticInfo. +const ( + ComponentCPU = "cpu" + ComponentMotherboard = "motherboard" + ComponentSystemUUID = "uuid" + ComponentMAC = "mac" + ComponentDisk = "disk" + ComponentMachineID = "machine-id" // Linux systemd machine-id +) + +// defaultTimeout is the default timeout for system command execution. +const defaultTimeout = 5 * time.Second + +// DiagnosticInfo contains information about what was collected during ID generation. +// Use [Provider.Diagnostics] to retrieve this information after calling [Provider.ID]. +type DiagnosticInfo struct { + Errors map[string]error // Component names that failed with their errors + Collected []string // Component names that were successfully collected +} + +// CommandExecutor is an interface for executing system commands, allowing for dependency injection and testing. +type CommandExecutor interface { + Execute(ctx context.Context, name string, args ...string) (string, error) +} + +// Provider configures and generates unique machine IDs. +// After the first call to ID(), the configuration is frozen and the result is cached. +// Provider methods are safe for concurrent use after configuration is complete. +type Provider struct { + commandExecutor CommandExecutor + logger *slog.Logger + diagnostics *DiagnosticInfo + salt string + cachedID string + formatMode FormatMode + mu sync.Mutex + includeCPU bool + includeMotherboard bool + includeSystemUUID bool + includeMAC bool + includeDisk bool +} + +// New creates a new Provider with default settings. +// The provider uses real system commands by default. +// Default format is Format64 (64 hex characters, 2^6). +func New() *Provider { + return &Provider{ + commandExecutor: &defaultCommandExecutor{ + Timeout: defaultTimeout, + }, + formatMode: Format64, + } +} + +// WithSalt sets a custom salt for additional entropy. +func (p *Provider) WithSalt(salt string) *Provider { + p.salt = salt + + return p +} + +// WithFormat sets the output format and length. +// Use Format64 (default), Format32, Format128, or Format256. +func (p *Provider) WithFormat(mode FormatMode) *Provider { + p.formatMode = mode + + return p +} + +// WithCPU includes the CPU identifier in the generation. +func (p *Provider) WithCPU() *Provider { + p.includeCPU = true + + return p +} + +// WithMotherboard includes the motherboard serial number in the generation. +func (p *Provider) WithMotherboard() *Provider { + p.includeMotherboard = true + + return p +} + +// WithSystemUUID includes the system UUID in the generation. +func (p *Provider) WithSystemUUID() *Provider { + p.includeSystemUUID = true + + return p +} + +// WithMAC includes network interface MAC addresses in the generation. +func (p *Provider) WithMAC() *Provider { + p.includeMAC = true + + return p +} + +// WithDisk includes disk serial numbers in the generation. +func (p *Provider) WithDisk() *Provider { + p.includeDisk = true + + return p +} + +// WithExecutor sets a custom [CommandExecutor], enabling deterministic testing +// without real system commands. +func (p *Provider) WithExecutor(executor CommandExecutor) *Provider { + p.commandExecutor = executor + + return p +} + +// WithLogger sets an optional [*slog.Logger] for observability. +// When set, the provider logs component collection, fallback paths, command +// execution timing, and errors. A nil logger (the default) disables all logging +// with zero overhead. +// +// Compatible with any [*slog.Logger], including [slog.Default] which bridges +// to the standard [log] package. +func (p *Provider) WithLogger(logger *slog.Logger) *Provider { + p.logger = logger + + return p +} + +// VMFriendly configures the provider for virtual machines (CPU + UUID only). +func (p *Provider) VMFriendly() *Provider { + p.includeCPU = true + p.includeSystemUUID = true + p.includeMotherboard = false + p.includeMAC = false + p.includeDisk = false + + return p +} + +// ID generates the machine ID based on the configured options. +// It caches the result, so subsequent calls return the same ID. +// The configuration is frozen after the first successful call to ID(). +// The provided context controls the timeout and cancellation of any +// system commands executed during hardware identifier collection. +// This method is safe for concurrent use. +func (p *Provider) ID(ctx context.Context) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.cachedID != "" { + p.logDebug("returning cached machine ID") + + return p.cachedID, nil + } + + p.logInfo("generating machine ID", + "platform", runtime.GOOS, + "format", p.formatMode, + "components", p.enabledComponents(), + ) + + diag := &DiagnosticInfo{ + Errors: make(map[string]error), + } + + identifiers, err := collectIdentifiers(ctx, p, diag) + if err != nil { + return "", fmt.Errorf("failed to collect hardware identifiers: %w", err) + } + + if len(identifiers) == 0 { + p.diagnostics = diag + p.logWarn("no hardware identifiers collected", "errors", diag.Errors) + + return "", ErrNoIdentifiers + } + + p.logDebug("collected identifiers", "count", len(identifiers), "identifiers", identifiers) + + p.diagnostics = diag + p.cachedID = hashIdentifiers(identifiers, p.salt, p.formatMode) + + p.logInfo("machine ID generated", + "collected", diag.Collected, + "errors_count", len(diag.Errors), + ) + + return p.cachedID, nil +} + +// Diagnostics returns information about which hardware components were +// successfully collected and which ones failed during the last call to [ID]. +// Returns nil if [ID] has not been called yet. +func (p *Provider) Diagnostics() *DiagnosticInfo { + p.mu.Lock() + defer p.mu.Unlock() + + return p.diagnostics +} + +// Validate checks if the provided ID matches the current machine ID. +// The provided context is forwarded to [ID] if it needs to generate the ID. +func (p *Provider) Validate(ctx context.Context, id string) (bool, error) { + currentID, err := p.ID(ctx) + if err != nil { + return false, err + } + + return currentID == id, nil +} + +// hashIdentifiers processes and hashes the hardware identifiers with optional salt. +// Returns a hash formatted according to the specified FormatMode. +func hashIdentifiers(identifiers []string, salt string, mode FormatMode) string { + sort.Strings(identifiers) + combined := strings.Join(identifiers, "|") + if salt != "" { + combined = salt + "|" + combined + } + + // Generate SHA256 hash + hash := sha256.Sum256([]byte(combined)) + rawHash := hex.EncodeToString(hash[:]) + + return formatHash(rawHash, mode) +} + +// formatHash formats a 64-character SHA-256 hash according to the specified mode. +// All formats produce power-of-2 lengths without dashes. +func formatHash(hash string, mode FormatMode) string { + if len(hash) != 64 { + return hash + } + + switch mode { + case Format32: + // 32 hex characters (2^5 = 32) + return hash[:32] + + case Format64: + // 64 hex characters (2^6 = 64), no dashes - default + return hash + + case Format128: + // 128 hex characters (2^7 = 128) + // Generate second hash by rehashing the first + hash2 := sha256.Sum256([]byte(hash)) + + return hash + hex.EncodeToString(hash2[:]) + + case Format256: + // 256 hex characters (2^8 = 256) + // Generate additional hashes for extended length + hash2 := sha256.Sum256([]byte(hash)) + hash3 := sha256.Sum256([]byte(hex.EncodeToString(hash2[:]))) + hash4 := sha256.Sum256([]byte(hex.EncodeToString(hash3[:]))) + return hash + hex.EncodeToString(hash2[:]) + + hex.EncodeToString(hash3[:]) + hex.EncodeToString(hash4[:]) + + default: + return hash + } +} + +// logDebug logs at debug level if a logger is configured. +func (p *Provider) logDebug(msg string, args ...any) { + if p.logger != nil { + p.logger.Debug(msg, args...) + } +} + +// logInfo logs at info level if a logger is configured. +func (p *Provider) logInfo(msg string, args ...any) { + if p.logger != nil { + p.logger.Info(msg, args...) + } +} + +// logWarn logs at warn level if a logger is configured. +func (p *Provider) logWarn(msg string, args ...any) { + if p.logger != nil { + p.logger.Warn(msg, args...) + } +} + +// enabledComponents returns the names of the hardware components that are enabled. +func (p *Provider) enabledComponents() []string { + var components []string + if p.includeCPU { + components = append(components, ComponentCPU) + } + if p.includeMotherboard { + components = append(components, ComponentMotherboard) + } + if p.includeSystemUUID { + components = append(components, ComponentSystemUUID) + } + if p.includeMAC { + components = append(components, ComponentMAC) + } + if p.includeDisk { + components = append(components, ComponentDisk) + } + + return components +} + +// appendIdentifierIfValid adds the result of getValue to identifiers with the given prefix if valid. +// It records the result in diag under the given component name. +func appendIdentifierIfValid(identifiers []string, getValue func() (string, error), prefix string, diag *DiagnosticInfo, component string, logger *slog.Logger) []string { + value, err := getValue() + if err != nil { + if diag != nil { + diag.Errors[component] = err + } + if logger != nil { + logger.Warn("component failed", "component", component, "error", err) + } + + return identifiers + } + + if value == "" { + if diag != nil { + diag.Errors[component] = ErrEmptyValue + } + if logger != nil { + logger.Warn("component returned empty value", "component", component) + } + + return identifiers + } + + if diag != nil { + diag.Collected = append(diag.Collected, component) + } + if logger != nil { + logger.Info("component collected", "component", component) + logger.Debug("component value", "component", component, "value", value) + } + + return append(identifiers, prefix+value) +} + +// appendIdentifiersIfValid adds the results of getValues to identifiers with the given prefix if valid. +// It records the result in diag under the given component name. +func appendIdentifiersIfValid(identifiers []string, getValues func() ([]string, error), prefix string, diag *DiagnosticInfo, component string, logger *slog.Logger) []string { + values, err := getValues() + if err != nil { + if diag != nil { + diag.Errors[component] = err + } + if logger != nil { + logger.Warn("component failed", "component", component, "error", err) + } + + return identifiers + } + + if len(values) == 0 { + if diag != nil { + diag.Errors[component] = ErrNoValues + } + if logger != nil { + logger.Warn("component returned no values", "component", component) + } + + return identifiers + } + + if diag != nil { + diag.Collected = append(diag.Collected, component) + } + if logger != nil { + logger.Info("component collected", "component", component, "count", len(values)) + logger.Debug("component values", "component", component, "values", values) + } + + for _, value := range values { + identifiers = append(identifiers, prefix+value) + } + + return identifiers +} diff --git a/machineid_internal_test.go b/machineid_internal_test.go new file mode 100644 index 0000000..952cfb1 --- /dev/null +++ b/machineid_internal_test.go @@ -0,0 +1,474 @@ +package machineid + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" +) + +// TestProviderWithMockExecutor tests using a mock executor for deterministic testing. +func TestProviderWithMockExecutor(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU Brand String") + + g := New(). + WithExecutor(mock). + WithCPU() + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("ID() with mock executor error = %v", err) + } + + if len(id) != 64 { + t.Errorf("ID() returned ID of length %d, expected 64", len(id)) + } + + // Verify the ID is consistent with the same mock + id2, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("Second ID() call error = %v", err) + } + + if id != id2 { + t.Error("ID() returned different IDs with same mock executor") + } +} + +// TestProviderErrorHandling tests various error conditions. +func TestProviderErrorHandling(t *testing.T) { + tests := []struct { + name string + setupMock func(*mockExecutor) + configure func(*Provider) *Provider + expectError bool + wantErr error + }{ + { + name: "command execution fails but no fallback available", + setupMock: func(m *mockExecutor) { + m.setError("sysctl", fmt.Errorf("command not found")) + }, + configure: func(p *Provider) *Provider { + return p.WithCPU() + }, + expectError: true, + wantErr: ErrNoIdentifiers, + }, + { + name: "no identifiers collected", + setupMock: func(m *mockExecutor) { + // All commands fail + m.setError("sysctl", fmt.Errorf("failed")) + m.setError("ioreg", fmt.Errorf("failed")) + m.setError("system_profiler", fmt.Errorf("failed")) + }, + configure: func(p *Provider) *Provider { + return p.WithCPU().WithSystemUUID() + }, + expectError: true, + wantErr: ErrNoIdentifiers, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := newMockExecutor() + if tt.setupMock != nil { + tt.setupMock(mock) + } + + p := New().WithExecutor(mock) + p = tt.configure(p) + + _, err := p.ID(context.Background()) + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if tt.expectError && err != nil && tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Errorf("got error %v, want %v", err, tt.wantErr) + } + } + }) + } +} + +// TestHashIdentifiersEmpty tests hashing with empty identifiers. +func TestHashIdentifiersEmpty(t *testing.T) { + result := hashIdentifiers([]string{}, "", Format64) + if len(result) != 64 { + t.Errorf("Expected 64-character hash, got %d", len(result)) + } +} + +// TestHashIdentifiersSorting tests that identifiers are sorted before hashing. +func TestHashIdentifiersSorting(t *testing.T) { + ids1 := []string{"cpu:intel", "uuid:123"} + ids2 := []string{"uuid:123", "cpu:intel"} + + hash1 := hashIdentifiers(ids1, "test", Format64) + hash2 := hashIdentifiers(ids2, "test", Format64) + + if hash1 != hash2 { + t.Error("Hash should be same regardless of input order") + } +} + +// TestHashIdentifiersWithoutSalt tests hashing without salt. +func TestHashIdentifiersWithoutSalt(t *testing.T) { + ids := []string{"test1", "test2"} + + withSalt := hashIdentifiers(ids, "mysalt", Format64) + withoutSalt := hashIdentifiers(ids, "", Format64) + + if withSalt == withoutSalt { + t.Error("Hash with salt should differ from hash without salt") + } +} + +// TestValidateError tests Validate method when ID generation fails. +func TestValidateError(t *testing.T) { + mock := newMockExecutor() + mock.setError("sysctl", fmt.Errorf("command failed")) + + p := New().WithExecutor(mock).WithCPU() + + valid, err := p.Validate(context.Background(), "some-id") + if err == nil { + t.Error("Expected error when ID generation fails") + } + if valid { + t.Error("Validation should fail when error occurs") + } +} + +// TestAppendIdentifierIfValidEmpty tests with empty value. +func TestAppendIdentifierIfValidEmpty(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + getValue := func() (string, error) { + return "", nil + } + + result := appendIdentifierIfValid([]string{"existing"}, getValue, "prefix:", diag, "test", nil) + if len(result) != 1 { + t.Errorf("Expected 1 identifier, got %d", len(result)) + } + if _, ok := diag.Errors["test"]; !ok { + t.Error("Expected error recorded in diagnostics for empty value") + } +} + +// TestAppendIdentifierIfValidError tests with error. +func TestAppendIdentifierIfValidError(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + getValue := func() (string, error) { + return "", fmt.Errorf("test error") + } + + result := appendIdentifierIfValid([]string{"existing"}, getValue, "prefix:", diag, "test", nil) + if len(result) != 1 { + t.Errorf("Expected 1 identifier (original), got %d", len(result)) + } + if _, ok := diag.Errors["test"]; !ok { + t.Error("Expected error recorded in diagnostics") + } +} + +// TestAppendIdentifierIfValidSuccess tests with valid value. +func TestAppendIdentifierIfValidSuccess(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + getValue := func() (string, error) { + return "good-value", nil + } + + result := appendIdentifierIfValid([]string{"existing"}, getValue, "prefix:", diag, "test", nil) + if len(result) != 2 { + t.Errorf("Expected 2 identifiers, got %d", len(result)) + } + if len(diag.Collected) != 1 || diag.Collected[0] != "test" { + t.Errorf("Expected 'test' in collected, got %v", diag.Collected) + } +} + +// TestAppendIdentifiersIfValidEmpty tests with empty array. +func TestAppendIdentifiersIfValidEmpty(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + getValues := func() ([]string, error) { + return []string{}, nil + } + + result := appendIdentifiersIfValid([]string{"existing"}, getValues, "prefix:", diag, "test", nil) + if len(result) != 1 { + t.Errorf("Expected 1 identifier, got %d", len(result)) + } +} + +// TestAppendIdentifiersIfValidError tests with error. +func TestAppendIdentifiersIfValidError(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + getValues := func() ([]string, error) { + return nil, fmt.Errorf("test error") + } + + result := appendIdentifiersIfValid([]string{"existing"}, getValues, "prefix:", diag, "test", nil) + if len(result) != 1 { + t.Errorf("Expected 1 identifier (original), got %d", len(result)) + } + if _, ok := diag.Errors["test"]; !ok { + t.Error("Expected error recorded in diagnostics") + } +} + +// TestAppendIdentifiersIfValidMultiple tests with multiple values. +func TestAppendIdentifiersIfValidMultiple(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + getValues := func() ([]string, error) { + return []string{"val1", "val2", "val3"}, nil + } + + result := appendIdentifiersIfValid([]string{"existing"}, getValues, "prefix:", diag, "test", nil) + if len(result) != 4 { + t.Errorf("Expected 4 identifiers, got %d", len(result)) + } + + // Check that prefix was added + if result[1] != "prefix:val1" { + t.Errorf("Expected 'prefix:val1', got '%s'", result[1]) + } + + // Check diagnostics + if len(diag.Collected) != 1 || diag.Collected[0] != "test" { + t.Errorf("Expected 'test' in collected, got %v", diag.Collected) + } +} + +// TestAppendIdentifierNilDiag tests that nil diagnostics don't panic. +func TestAppendIdentifierNilDiag(t *testing.T) { + getValue := func() (string, error) { + return "value", nil + } + + result := appendIdentifierIfValid(nil, getValue, "prefix:", nil, "test", nil) + if len(result) != 1 { + t.Errorf("Expected 1 identifier, got %d", len(result)) + } +} + +// TestDiagnosticsAvailableAfterID tests that Diagnostics() returns data after ID(). +func TestDiagnosticsAvailableAfterID(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU") + mock.setOutput("system_profiler", `{ + "SPHardwareDataType": [{ + "chip_type": "Apple M1", + "machine_model": "Mac", + "platform_UUID": "UUID-123", + "serial_number": "SERIAL" + }] + }`) + + p := New().WithExecutor(mock).WithCPU().WithSystemUUID() + + // Before ID(), Diagnostics should be nil + if p.Diagnostics() != nil { + t.Error("Diagnostics should be nil before ID() call") + } + + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + diag := p.Diagnostics() + if diag == nil { + t.Fatal("Diagnostics should not be nil after ID() call") + } + + if len(diag.Collected) == 0 { + t.Error("Expected at least one collected component") + } +} + +// TestDiagnosticsRecordsFailures tests that failed components are recorded. +func TestDiagnosticsRecordsFailures(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU") + mock.setOutput("system_profiler", `{ + "SPHardwareDataType": [{ + "chip_type": "Apple M1", + "machine_model": "Mac", + "platform_UUID": "", + "serial_number": "" + }] + }`) + mock.setError("ioreg", fmt.Errorf("ioreg not available")) + + p := New().WithExecutor(mock).WithCPU().WithSystemUUID() + + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + diag := p.Diagnostics() + if diag == nil { + t.Fatal("Diagnostics should not be nil") + } + + // CPU should succeed, UUID should fail (empty in JSON + ioreg fails) + if len(diag.Collected) == 0 { + t.Error("Expected at least one collected component") + } +} + +// TestProviderCachedIDNotModified tests that cached ID is not modified on subsequent calls. +func TestProviderCachedIDNotModified(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "CPU1") + + p := New().WithExecutor(mock).WithCPU() + + id1, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("First ID() call failed: %v", err) + } + + // Change the mock output + mock.setOutput("sysctl", "CPU2") + + // Should still return cached value + id2, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("Second ID() call failed: %v", err) + } + + if id1 != id2 { + t.Error("Cached ID was modified on subsequent call") + } + + // Verify mock was only called once (due to caching) + if mock.callCount["sysctl"] > 2 { + t.Errorf("Expected sysctl to be called at most twice, got %d", mock.callCount["sysctl"]) + } +} + +// TestProviderAllIdentifiers tests using all identifier types. +func TestProviderAllIdentifiers(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Intel CPU") + mock.setOutput("system_profiler", `platform_UUID: "UUID123"`) + mock.setOutput("ioreg", "some data") + mock.setOutput("diskutil", `/dev/disk0`) + + p := New(). + WithExecutor(mock). + WithCPU(). + WithSystemUUID(). + WithMotherboard(). + WithMAC(). + WithDisk() + + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() with all identifiers failed: %v", err) + } + + if len(id) != 64 { + t.Errorf("Expected 64-character ID (default Format64), got %d", len(id)) + } +} + +// TestCollectIdentifiersError tests when collectIdentifiers returns an error. +func TestCollectIdentifiersError(t *testing.T) { + mock := newMockExecutor() + // Don't set any outputs, so all commands will fail with "not configured" + + p := New().WithExecutor(mock).WithCPU() + + _, err := p.ID(context.Background()) + if err == nil { + t.Error("Expected error when collectIdentifiers fails") + } +} + +// TestProviderValidateMismatch tests validation with mismatched ID. +func TestProviderValidateMismatch(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "CPU1") + + p := New().WithExecutor(mock).WithCPU() + + // Generate ID + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() failed: %v", err) + } + + // Validate with different ID + valid, err := p.Validate(context.Background(), id+"different") + if err != nil { + t.Errorf("Validate() error: %v", err) + } + + if valid { + t.Error("Expected validation to fail for different ID") + } +} + +// TestWithLoggerOutput verifies that log output appears when a logger is set. +func TestWithLoggerOutput(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU Brand") + + p := New(). + WithExecutor(mock). + WithLogger(logger). + WithCPU() + + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + output := buf.String() + if output == "" { + t.Error("Expected log output when logger is set, got empty string") + } + + // Check for key log messages + if !bytes.Contains(buf.Bytes(), []byte("generating machine ID")) { + t.Error("Expected 'generating machine ID' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("machine ID generated")) { + t.Error("Expected 'machine ID generated' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("component collected")) { + t.Error("Expected 'component collected' in log output") + } +} + +// TestWithoutLoggerNoOutput verifies that no logging occurs without a logger. +func TestWithoutLoggerNoOutput(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU Brand") + + p := New(). + WithExecutor(mock). + WithCPU() + + // Should not panic or produce any output + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } +} diff --git a/machineid_test.go b/machineid_test.go new file mode 100644 index 0000000..902de0d --- /dev/null +++ b/machineid_test.go @@ -0,0 +1,737 @@ +package machineid_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/slashdevops/machineid" +) + +// ExampleProvider demonstrates basic usage of the machineid package. +// This example shows how to generate a unique machine identifier +// that is stable across reboots and suitable for licensing. +func ExampleProvider() { + // Create a new provider with CPU and System UUID + // These are typically stable identifiers on most systems + provider := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithSalt("my-application-v1") + + // Generate the machine ID + id, err := provider.ID(context.Background()) + if err != nil { + fmt.Printf("Error generating ID: %v\n", err) + return + } + + // The ID is a 64-character hexadecimal string (SHA-256 hash, power of 2) + fmt.Printf("Machine ID length: %d\n", len(id)) + fmt.Printf("Machine ID is hexadecimal: %v\n", isHexString(id)) + + // Validate the ID + valid, err := provider.Validate(context.Background(), id) + if err != nil { + fmt.Printf("Error validating ID: %v\n", err) + return + } + fmt.Printf("ID is valid: %v\n", valid) + + // Output: + // Machine ID length: 64 + // Machine ID is hexadecimal: true + // ID is valid: true +} + +// ExampleProvider_VMFriendly demonstrates creating a VM-friendly machine ID. +// This configuration works well in virtual machine environments where +// hardware like disk serials and MAC addresses may change frequently. +func ExampleProvider_VMFriendly() { + provider := machineid.New(). + VMFriendly(). + WithSalt("vm-app") + + id, err := provider.ID(context.Background()) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Generated VM-friendly ID: %v\n", len(id) == 64) + // Output: + // Generated VM-friendly ID: true +} + +// isHexString reports whether s is a non-empty string of lowercase hex digits. +func isHexString(s string) bool { + if len(s) == 0 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func TestProviderBasic(t *testing.T) { + g := machineid.New().WithCPU().WithSystemUUID().WithMotherboard().WithMAC().WithDisk() + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error = %v", err) + } + + if len(id) != 64 { + t.Errorf("ID() returned ID of length %d, expected 64", len(id)) + } + + // Test consistency + id2, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("ID() second call error = %v", err) + } + + if id != id2 { + t.Error("ID() returned different IDs on consecutive calls") + } +} + +func TestProviderWithSalt(t *testing.T) { + salt := "test-salt" + g := machineid.New().WithCPU().WithSystemUUID().WithSalt(salt) + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("ID() with salt error = %v", err) + } + + if len(id) != 64 { + t.Errorf("ID() returned ID of length %d, expected 64", len(id)) + } + + // Different salts should produce different IDs + g2 := machineid.New().WithCPU().WithSystemUUID().WithSalt("different-salt") + id2, err := g2.ID(context.Background()) + if err != nil { + t.Fatalf("ID() with different salt error = %v", err) + } + + if id == id2 { + t.Error("ID() should return different IDs for different salts") + } +} + +func TestProviderValidate(t *testing.T) { + g := machineid.New().WithCPU().WithSystemUUID() + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error = %v", err) + } + + valid, err := g.Validate(context.Background(), id) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if !valid { + t.Error("Validate() returned false for valid ID") + } + + // Test with invalid ID + valid, err = g.Validate(context.Background(), "invalid-id") + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + + if valid { + t.Error("Validate() returned true for invalid ID") + } +} + +func TestVMFriendly(t *testing.T) { + g := machineid.New().VMFriendly().WithSalt("vm-test") + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("VMFriendly().ID() error = %v", err) + } + + if len(id) != 64 { + t.Errorf("VMFriendly().ID() returned ID of length %d, expected 64", len(id)) + } + + // Test that it's different from full hardware + g2 := machineid.New().WithCPU().WithSystemUUID().WithMotherboard().WithMAC().WithDisk().WithSalt("vm-test") + id2, err := g2.ID(context.Background()) + if err != nil { + t.Fatalf("Full hardware ID() error = %v", err) + } + + if id == id2 { + t.Error("VMFriendly() should produce different ID from full hardware") + } +} + +func TestNoIdentifiersError(t *testing.T) { + g := machineid.New() // No identifiers enabled + + _, err := g.ID(context.Background()) + if err == nil { + t.Error("ID() should return error when no identifiers are enabled") + } + + if !errors.Is(err, machineid.ErrNoIdentifiers) { + t.Errorf("ID() error should be ErrNoIdentifiers, got %v", err) + } +} + +func TestProviderChaining(t *testing.T) { + // Test that method chaining works + g := machineid.New(). + WithSalt("chain-test"). + WithCPU(). + WithSystemUUID(). + WithMotherboard() + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("Chained provider ID() error = %v", err) + } + + if len(id) != 64 { + t.Errorf("Chained provider ID() returned ID of length %d, expected 64", len(id)) + } + + // Verify it validates correctly + valid, err := g.Validate(context.Background(), id) + if err != nil { + t.Fatalf("Chained provider Validate() error = %v", err) + } + + if !valid { + t.Error("Chained provider Validate() returned false for valid ID") + } +} + +// TestProviderConcurrency tests that ID() is safe for concurrent use. +func TestProviderConcurrency(t *testing.T) { + g := machineid.New().WithCPU().WithSystemUUID() + + // Call ID() concurrently from multiple goroutines + const numGoroutines = 10 + results := make(chan string, numGoroutines) + errors := make(chan error, numGoroutines) + + for range numGoroutines { + go func() { + id, err := g.ID(context.Background()) + if err != nil { + errors <- err + return + } + results <- id + }() + } + + // Collect results + var ids []string + for range numGoroutines { + select { + case id := <-results: + ids = append(ids, id) + case err := <-errors: + t.Fatalf("Concurrent ID() call failed: %v", err) + } + } + + // All IDs should be identical + firstID := ids[0] + for i, id := range ids { + if id != firstID { + t.Errorf("ID mismatch at index %d: got %s, want %s", i, id, firstID) + } + } +} + +// TestValidateWithDifferentConfiguration tests validation behavior. +func TestValidateWithDifferentConfiguration(t *testing.T) { + g1 := machineid.New().WithCPU().WithSystemUUID() + id1, err := g1.ID(context.Background()) + if err != nil { + t.Fatalf("g1.ID(context.Background()) error = %v", err) + } + + // Same configuration should validate + g2 := machineid.New().WithCPU().WithSystemUUID() + valid, err := g2.Validate(context.Background(), id1) + if err != nil { + t.Fatalf("g2.Validate() error = %v", err) + } + + if !valid { + t.Error("Same configuration should produce valid ID") + } + + // Different configuration should not validate + g3 := machineid.New().WithCPU() // Missing SystemUUID + valid, err = g3.Validate(context.Background(), id1) + if err != nil { + t.Fatalf("g3.Validate() error = %v", err) + } + + if valid { + t.Error("Different configuration should not validate") + } +} + +// TestFormat32 tests the 32-character format (2^5). +func TestFormat32(t *testing.T) { + g := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(machineid.Format32) + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("Format32 ID() error = %v", err) + } + + if len(id) != 32 { + t.Errorf("Format32 ID() returned ID of length %d, expected 32", len(id)) + } + + // Verify it's all hex characters + for _, c := range id { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Errorf("Format32 ID contains non-hex character: %c", c) + } + } +} + +// TestFormat64 tests the 64-character format (2^6) - default. +func TestFormat64(t *testing.T) { + g := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(machineid.Format64) + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("Format64 ID() error = %v", err) + } + + if len(id) != 64 { + t.Errorf("Format64 ID() returned ID of length %d, expected 64", len(id)) + } + + // Verify it's all hex characters (no dashes) + for _, c := range id { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Errorf("Format64 ID contains non-hex character: %c", c) + } + } +} + +// TestFormat128 tests the 128-character format (2^7). +func TestFormat128(t *testing.T) { + g := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(machineid.Format128) + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("Format128 ID() error = %v", err) + } + + if len(id) != 128 { + t.Errorf("Format128 ID() returned ID of length %d, expected 128", len(id)) + } + + // Verify it's all hex characters + for _, c := range id { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Errorf("Format128 ID contains non-hex character: %c", c) + } + } +} + +// TestFormat256 tests the 256-character format (2^8). +func TestFormat256(t *testing.T) { + g := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(machineid.Format256) + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("Format256 ID() error = %v", err) + } + + if len(id) != 256 { + t.Errorf("Format256 ID() returned ID of length %d, expected 256", len(id)) + } + + // Verify it's all hex characters + for _, c := range id { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Errorf("Format256 ID contains non-hex character: %c", c) + } + } +} + +// TestFormatDifference tests that different formats produce different outputs. +func TestFormatDifference(t *testing.T) { + // Create providers with same config but different formats + g32 := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format32) + g64 := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format64) + g128 := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format128) + + id32, _ := g32.ID(context.Background()) + id64, _ := g64.ID(context.Background()) + id128, _ := g128.ID(context.Background()) + + // Format32 should be the first 32 chars of Format64 + if id32 != id64[:32] { + t.Error("Format32 should be first 32 chars of Format64") + } + + // Format64 should be the first 64 chars of Format128 + if id64 != id128[:64] { + t.Error("Format64 should be first 64 chars of Format128") + } +} + +// TestFormatDefault tests that the default format is Format64. +func TestFormatDefault(t *testing.T) { + // Create provider without explicit format + g := machineid.New().WithCPU().WithSystemUUID() + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("Default ID() error = %v", err) + } + + // Default should be 64 characters + if len(id) != 64 { + t.Errorf("Default format should be 64 characters, got %d", len(id)) + } + + // Should match explicit Format64 + g64 := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format64) + id64, err := g64.ID(context.Background()) + if err != nil { + t.Fatalf("Format64 ID() error = %v", err) + } + + if id != id64 { + t.Error("Default format should match Format64") + } +} + +// TestFormatConsistency tests that each format produces consistent results. +func TestFormatConsistency(t *testing.T) { + formats := []struct { + name string + format machineid.FormatMode + length int + }{ + {"Format32", machineid.Format32, 32}, + {"Format64", machineid.Format64, 64}, + {"Format128", machineid.Format128, 128}, + {"Format256", machineid.Format256, 256}, + } + + for _, tc := range formats { + t.Run(tc.name, func(t *testing.T) { + g := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(tc.format) + + // Generate ID multiple times + id1, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("%s first ID() error = %v", tc.name, err) + } + + id2, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("%s second ID() error = %v", tc.name, err) + } + + // Should be identical (cached) + if id1 != id2 { + t.Errorf("%s should produce consistent results, got different IDs", tc.name) + } + + // Should have correct length + if len(id1) != tc.length { + t.Errorf("%s should have length %d, got %d", tc.name, tc.length, len(id1)) + } + }) + } +} + +// TestFormatNoDashes tests that all formats produce output without dashes. +func TestFormatNoDashes(t *testing.T) { + formats := []struct { + name string + format machineid.FormatMode + }{ + {"Format32", machineid.Format32}, + {"Format64", machineid.Format64}, + {"Format128", machineid.Format128}, + {"Format256", machineid.Format256}, + } + + for _, tc := range formats { + t.Run(tc.name, func(t *testing.T) { + g := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(tc.format) + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("%s ID() error = %v", tc.name, err) + } + + // Check for dashes + for i, c := range id { + if c == '-' { + t.Errorf("%s should not contain dashes, found dash at position %d", tc.name, i) + } + } + }) + } +} + +// TestFormatWithDifferentHardware tests formats with various hardware configurations. +func TestFormatWithDifferentHardware(t *testing.T) { + configs := []struct { + name string + setup func(*machineid.Provider) *machineid.Provider + }{ + { + name: "CPU only", + setup: func(p *machineid.Provider) *machineid.Provider { + return p.WithCPU() + }, + }, + { + name: "UUID only", + setup: func(p *machineid.Provider) *machineid.Provider { + return p.WithSystemUUID() + }, + }, + { + name: "CPU + UUID", + setup: func(p *machineid.Provider) *machineid.Provider { + return p.WithCPU().WithSystemUUID() + }, + }, + { + name: "All hardware", + setup: func(p *machineid.Provider) *machineid.Provider { + return p.WithCPU().WithSystemUUID().WithMotherboard().WithMAC().WithDisk() + }, + }, + } + + formats := []machineid.FormatMode{ + machineid.Format32, + machineid.Format64, + machineid.Format128, + machineid.Format256, + } + + for _, config := range configs { + for _, format := range formats { + t.Run(fmt.Sprintf("%s_Format%d", config.name, []int{32, 64, 128, 256}[format]), func(t *testing.T) { + p := machineid.New() + p = config.setup(p) + p = p.WithFormat(format) + + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error = %v", err) + } + + // Verify it's not empty + if id == "" { + t.Error("ID should not be empty") + } + + // Verify it's hexadecimal + for _, c := range id { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Errorf("ID contains non-hex character: %c", c) + break + } + } + }) + } + } +} + +// TestFormatWithSalt tests that all formats work correctly with salt. +func TestFormatWithSalt(t *testing.T) { + salt := "test-salt-12345" + + formats := []struct { + name string + format machineid.FormatMode + length int + }{ + {"Format32", machineid.Format32, 32}, + {"Format64", machineid.Format64, 64}, + {"Format128", machineid.Format128, 128}, + {"Format256", machineid.Format256, 256}, + } + + for _, tc := range formats { + t.Run(tc.name, func(t *testing.T) { + // With salt + g1 := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithSalt(salt). + WithFormat(tc.format) + + id1, err := g1.ID(context.Background()) + if err != nil { + t.Fatalf("%s with salt ID() error = %v", tc.name, err) + } + + // Without salt + g2 := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(tc.format) + + id2, err := g2.ID(context.Background()) + if err != nil { + t.Fatalf("%s without salt ID() error = %v", tc.name, err) + } + + // IDs should be different + if id1 == id2 { + t.Errorf("%s: IDs with and without salt should differ", tc.name) + } + + // Both should have correct length + if len(id1) != tc.length { + t.Errorf("%s with salt: expected length %d, got %d", tc.name, tc.length, len(id1)) + } + if len(id2) != tc.length { + t.Errorf("%s without salt: expected length %d, got %d", tc.name, tc.length, len(id2)) + } + }) + } +} + +// TestFormatValidation tests validation with different formats. +func TestFormatValidation(t *testing.T) { + formats := []struct { + name string + format machineid.FormatMode + }{ + {"Format32", machineid.Format32}, + {"Format64", machineid.Format64}, + {"Format128", machineid.Format128}, + {"Format256", machineid.Format256}, + } + + for _, tc := range formats { + t.Run(tc.name, func(t *testing.T) { + g := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(tc.format) + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("%s ID() error = %v", tc.name, err) + } + + // Validation should succeed with same format + valid, err := g.Validate(context.Background(), id) + if err != nil { + t.Fatalf("%s Validate() error = %v", tc.name, err) + } + if !valid { + t.Errorf("%s Validate() should return true for matching ID", tc.name) + } + + // Validation should fail with different format + var differentFormat machineid.FormatMode + if tc.format == machineid.Format32 { + differentFormat = machineid.Format64 + } else { + differentFormat = machineid.Format32 + } + + g2 := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(differentFormat) + + valid2, err := g2.Validate(context.Background(), id) + if err != nil { + t.Fatalf("%s Validate() with different format error = %v", tc.name, err) + } + if valid2 { + t.Errorf("%s Validate() should return false for different format", tc.name) + } + }) + } +} + +// TestFormatPowerOfTwo verifies that all format lengths are powers of 2. +func TestFormatPowerOfTwo(t *testing.T) { + formats := []struct { + name string + format machineid.FormatMode + length int + power int + }{ + {"Format32", machineid.Format32, 32, 5}, + {"Format64", machineid.Format64, 64, 6}, + {"Format128", machineid.Format128, 128, 7}, + {"Format256", machineid.Format256, 256, 8}, + } + + for _, tc := range formats { + t.Run(tc.name, func(t *testing.T) { + g := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithFormat(tc.format) + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("%s ID() error = %v", tc.name, err) + } + + // Verify length + if len(id) != tc.length { + t.Errorf("%s: expected length %d, got %d", tc.name, tc.length, len(id)) + } + + // Verify it's a power of 2 + power := 1 + for i := 0; i < tc.power; i++ { + power *= 2 + } + if len(id) != power { + t.Errorf("%s: length should be 2^%d = %d, got %d", tc.name, tc.power, power, len(id)) + } + }) + } +} diff --git a/network.go b/network.go new file mode 100644 index 0000000..0202b40 --- /dev/null +++ b/network.go @@ -0,0 +1,84 @@ +package machineid + +import ( + "log/slog" + "net" + "strings" +) + +// virtualInterfacePrefixes lists interface name prefixes that represent +// virtual, VPN, bridge, or ephemeral interfaces. These are excluded because +// they change when software is installed/removed or connections are started/stopped. +var virtualInterfacePrefixes = []string{ + // VPN and tunnel interfaces + "utun", "tun", "tap", "ipsec", "ppp", + // Docker and container bridges + "docker", "br-", "veth", + // Virtual bridges and switches + "virbr", "vnet", "vmnet", + // Thunderbolt bridge (changes with docking state) + "bridge", + // Loopback variants + "lo", + // WireGuard + "wg", + // Parallels / VirtualBox / VMware + "vnic", "vboxnet", +} + +// collectMACAddresses retrieves MAC addresses from physical network interfaces. +// Virtual, VPN, bridge, and container interfaces are excluded for stability. +func collectMACAddresses(logger *slog.Logger) ([]string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + var macs []string + + for _, i := range interfaces { + // Skip loopback interfaces and those without MAC addresses. + if i.Flags&net.FlagLoopback != 0 || len(i.HardwareAddr) == 0 { + continue + } + + // Skip interfaces that are not up β€” they may be transient. + if i.Flags&net.FlagUp == 0 { + if logger != nil { + logger.Debug("skipping interface (not up)", "interface", i.Name) + } + + continue + } + + // Skip virtual/VPN/bridge interfaces that are not stable hardware. + if isVirtualInterface(i.Name) { + if logger != nil { + logger.Debug("skipping virtual interface", "interface", i.Name) + } + + continue + } + + if logger != nil { + logger.Debug("including interface", "interface", i.Name, "mac", i.HardwareAddr.String()) + } + + macs = append(macs, i.HardwareAddr.String()) + } + + return macs, nil +} + +// isVirtualInterface returns true if the interface name matches a known +// virtual, VPN, or bridge prefix. +func isVirtualInterface(name string) bool { + lower := strings.ToLower(name) + for _, prefix := range virtualInterfacePrefixes { + if strings.HasPrefix(lower, prefix) { + return true + } + } + + return false +} diff --git a/network_test.go b/network_test.go new file mode 100644 index 0000000..676baa7 --- /dev/null +++ b/network_test.go @@ -0,0 +1,47 @@ +package machineid + +import ( + "testing" +) + +// TestCollectMACAddresses tests network interface MAC address collection. +func TestCollectMACAddresses(t *testing.T) { + macs, err := collectMACAddresses(nil) + if err != nil { + t.Logf("collectMACAddresses error (might be expected in some environments): %v", err) + } + t.Logf("Found %d MAC addresses (filtered)", len(macs)) +} + +// TestIsVirtualInterface tests virtual interface detection. +func TestIsVirtualInterface(t *testing.T) { + tests := []struct { + name string + expected bool + }{ + {"utun0", true}, + {"utun1", true}, + {"docker0", true}, + {"br-abc123", true}, + {"veth1234", true}, + {"bridge0", true}, + {"vmnet1", true}, + {"lo0", true}, + {"wg0", true}, + {"vnic0", true}, + {"en0", false}, + {"en1", false}, + {"eth0", false}, + {"wlan0", false}, + {"Wi-Fi", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isVirtualInterface(tt.name) + if result != tt.expected { + t.Errorf("isVirtualInterface(%q) = %v, want %v", tt.name, result, tt.expected) + } + }) + } +} diff --git a/windows.go b/windows.go new file mode 100644 index 0000000..e532855 --- /dev/null +++ b/windows.go @@ -0,0 +1,224 @@ +//go:build windows + +package machineid + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" +) + +// collectIdentifiers gathers Windows-specific hardware identifiers based on provider config. +func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) ([]string, error) { + var identifiers []string + logger := p.logger + + if p.includeCPU { + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return windowsCPUID(ctx, p.commandExecutor, logger) + }, "cpu:", diag, ComponentCPU, logger) + } + + if p.includeMotherboard { + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return windowsMotherboardSerial(ctx, p.commandExecutor, logger) + }, "mb:", diag, ComponentMotherboard, logger) + } + + if p.includeSystemUUID { + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return windowsSystemUUID(ctx, p.commandExecutor, logger) + }, "uuid:", diag, ComponentSystemUUID, logger) + } + + if p.includeMAC { + identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { + return collectMACAddresses(logger) + }, "mac:", diag, ComponentMAC, logger) + } + + if p.includeDisk { + identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { + return windowsDiskSerials(ctx, p.commandExecutor, logger) + }, "disk:", diag, ComponentDisk, logger) + } + + return identifiers, nil +} + +// parseWmicValue extracts value from wmic output with given prefix. +func parseWmicValue(output, prefix string) (string, error) { + lines := strings.SplitSeq(output, "\n") + + for line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + value := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if value == "" || value == biosFirmwareMessage { + continue + } + + return value, nil + } + } + + return "", fmt.Errorf("value with prefix %s not found", prefix) +} + +// parseWmicMultipleValues extracts all values from wmic output with given prefix. +func parseWmicMultipleValues(output, prefix string) []string { + var values []string + lines := strings.SplitSeq(output, "\n") + + for line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + value := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if value == "" || value == biosFirmwareMessage { + continue + } + values = append(values, value) + } + } + + return values +} + +// parsePowerShellValue extracts a trimmed, non-empty value from PowerShell output. +func parsePowerShellValue(output string) (string, error) { + value := strings.TrimSpace(output) + if value == "" { + return "", errors.New("empty value from PowerShell") + } + + return value, nil +} + +// parsePowerShellMultipleValues extracts multiple trimmed, non-empty values from PowerShell output. +func parsePowerShellMultipleValues(output string) []string { + var values []string + lines := strings.SplitSeq(output, "\n") + + for line := range lines { + value := strings.TrimSpace(line) + if value != "" { + values = append(values, value) + } + } + + return values +} + +// windowsCPUID retrieves CPU processor ID using wmic, with PowerShell fallback. +func windowsCPUID(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + output, err := executeCommand(ctx, executor, logger, "wmic", "cpu", "get", "ProcessorId", "/value") + if err == nil { + if value, parseErr := parseWmicValue(output, "ProcessorId="); parseErr == nil { + return value, nil + } + } + + // Fallback to PowerShell Get-CimInstance + if logger != nil { + logger.Info("falling back to PowerShell for CPU ID") + } + + psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", + "Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty ProcessorId") + if psErr != nil { + return "", fmt.Errorf("failed to get CPU ID: wmic: %w, powershell: %w", err, psErr) + } + + return parsePowerShellValue(psOutput) +} + +// windowsMotherboardSerial retrieves motherboard serial number using wmic, with PowerShell fallback. +func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + output, err := executeCommand(ctx, executor, logger, "wmic", "baseboard", "get", "SerialNumber", "/value") + if err == nil { + if value, parseErr := parseWmicValue(output, "SerialNumber="); parseErr == nil { + return value, nil + } + } + + // Fallback to PowerShell Get-CimInstance + if logger != nil { + logger.Info("falling back to PowerShell for motherboard serial") + } + + psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", + "Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -ExpandProperty SerialNumber") + if psErr != nil { + return "", fmt.Errorf("failed to get motherboard serial: wmic: %w, powershell: %w", err, psErr) + } + + value, parseErr := parsePowerShellValue(psOutput) + if parseErr != nil { + return "", parseErr + } + + if value == biosFirmwareMessage { + return "", errors.New("motherboard serial is OEM placeholder") + } + + return value, nil +} + +// windowsSystemUUID retrieves system UUID using wmic or PowerShell. +func windowsSystemUUID(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + // Try wmic first + output, err := executeCommand(ctx, executor, logger, "wmic", "csproduct", "get", "UUID", "/value") + if err == nil { + if value, parseErr := parseWmicValue(output, "UUID="); parseErr == nil { + return value, nil + } + } + + // Fallback to PowerShell + if logger != nil { + logger.Info("falling back to PowerShell for system UUID") + } + + return windowsSystemUUIDViaPowerShell(ctx, executor, logger) +} + +// windowsSystemUUIDViaPowerShell retrieves system UUID using PowerShell. +func windowsSystemUUIDViaPowerShell(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { + output, err := executeCommand(ctx, executor, logger, "powershell", "-Command", + "Get-CimInstance -ClassName Win32_ComputerSystemProduct | Select-Object -ExpandProperty UUID") + if err != nil { + return "", fmt.Errorf("failed to get UUID via PowerShell: %w", err) + } + + return parsePowerShellValue(output) +} + +// windowsDiskSerials retrieves disk serial numbers using wmic, with PowerShell fallback. +func windowsDiskSerials(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) { + output, err := executeCommand(ctx, executor, logger, "wmic", "diskdrive", "get", "SerialNumber", "/value") + if err == nil { + if values := parseWmicMultipleValues(output, "SerialNumber="); len(values) > 0 { + return values, nil + } + } + + // Fallback to PowerShell Get-CimInstance + if logger != nil { + logger.Info("falling back to PowerShell for disk serials") + } + + psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", + "Get-CimInstance -ClassName Win32_DiskDrive | Select-Object -ExpandProperty SerialNumber") + if psErr != nil { + return nil, fmt.Errorf("failed to get disk serials: wmic: %w, powershell: %w", err, psErr) + } + + values := parsePowerShellMultipleValues(psOutput) + if len(values) == 0 { + return nil, errors.New("no disk serials found via PowerShell") + } + + return values, nil +}