diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ca48482..347df1a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -49,3 +49,15 @@ Since this is a library build in native go, the files are mostly organized follo - 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. + +## Post-Change Checklist + +Prefer the Make targets that the repo already defines after making changes: + +```bash +make go-fmt # Format code +make go-betteralign # Align struct fields for optimal memory layout +golangci-lint run ./... # Run linter (also checks formatting, vet, and other issues) +make build # Verify build +make test # Run tests +``` diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index aa791fb..0890905 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,7 +10,7 @@ permissions: pull-requests: read jobs: - build: + test: runs-on: ubuntu-latest steps: - name: Checkout @@ -94,7 +94,7 @@ jobs: run: | echo "### Test report" >> $GITHUB_STEP_SUMMARY - go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY + make test | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - name: Test coverage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4901263..d5fa9b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,13 @@ 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]* + workflow_dispatch: + +env: + MAKE_STOP_ON_ERRORS: true permissions: id-token: write @@ -12,11 +15,10 @@ permissions: actions: write contents: write pull-requests: read - packages: write jobs: - release: - name: Release + test: + name: Test runs-on: ubuntu-latest steps: - name: Check out code @@ -28,90 +30,316 @@ jobs: with: go-version-file: ./go.mod - - name: Summary Information + - name: Go version + run: go version + + - name: Test + run: make test + + build-linux: + name: Build Distribution (Linux) + needs: test + 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: Go version + run: go version + + - name: Build Linux distribution 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 }} + GIT_VERSION=${{ github.ref_name }} GO_OS="linux" make build-dist + + - name: Install Cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign Linux binaries with Cosign (keyless) 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") + for arch in arm64 amd64; do + for bin in machineid; do + cosign sign-blob \ + --yes \ + --bundle "./dist/${bin}-linux-${arch}.sigstore.json" \ + "./dist/${bin}-linux-${arch}" + done + done - gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME + - name: Upload Linux distribution files + uses: actions/upload-artifact@v6 + with: + name: dist-linux + path: ./dist/ - # Extract based on file extension - if [[ "$ASSETS_NAME" == *.tar.gz ]]; then - tar -xzf $ASSETS_NAME - elif [[ "$ASSETS_NAME" == *.zip ]]; then - unzip $ASSETS_NAME - fi + # build-sign-windows: + # name: Build & Sign Windows + # needs: test + # runs-on: windows-latest + # steps: + # - name: Check out code + # uses: actions/checkout@v6 + # + # - name: Set up Go 1.x + # uses: actions/setup-go@v6 + # with: + # go-version-file: ./go.mod + # + # - name: Go version + # run: go version + # + # - name: Build Windows binaries (arm64 & amd64) + # shell: pwsh + # run: | + # $ErrorActionPreference = 'Stop' + # $gitVersion = "${{ github.ref_name }}" + # $gitCommit = (git rev-parse HEAD).Trim() + # $gitBranch = (git rev-parse --abbrev-ref HEAD).Trim() + # $buildDate = [System.DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + # $gitUser = (git config --get user.email).Trim() + # $ns = "github.com/slashdevops/machineid/internal/version" + # $ldflags = "-s -w " + + # "-X `"${ns}.Version=${gitVersion}`" " + + # "-X `"${ns}.BuildDate=${buildDate}`" " + + # "-X `"${ns}.GitCommit=${gitCommit}`" " + + # "-X `"${ns}.GitBranch=${gitBranch}`" " + + # "-X `"${ns}.BuildUser=${gitUser}`" + # + # New-Item -ItemType Directory -Force -Path ./dist | Out-Null + # $env:CGO_ENABLED = "0" + # $env:GOOS = "windows" + # + # foreach ($arch in @("arm64", "amd64")) { + # $env:GOARCH = $arch + # foreach ($bin in @("machineid")) { + # Write-Host "Building ${bin}-windows-${arch}.exe" + # go build -v -ldflags $ldflags -o "./dist/${bin}-windows-${arch}.exe" "./cmd/${bin}/" + # } + # } + # + # - name: Azure Login + # uses: azure/login@v2 + # with: + # creds: ${{ secrets.AZURE_CREDENTIALS }} + # + # - name: Install Azure Trusted Signing PowerShell module + # shell: pwsh + # run: | + # Install-Module -Name TrustedSigning -RequiredVersion 0.4.1 -Force -Repository PSGallery + # Get-Module -ListAvailable -Name TrustedSigning + # + # - name: Sign Windows binaries + # shell: pwsh + # env: + # AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + # AZURE_TRUSTED_SIGNING_ACCOUNT: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT }} + # AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + # run: | + # Invoke-TrustedSigning ` + # -Endpoint $env:AZURE_TRUSTED_SIGNING_ENDPOINT ` + # -AccountName $env:AZURE_TRUSTED_SIGNING_ACCOUNT ` + # -CertificateProfileName $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE ` + # -FilesFolder "./dist" ` + # -FilesFolderFilter "*.exe" ` + # -FilesFolderRecurse $false ` + # -FileDigest SHA256 ` + # -TimestampRfc3161 "http://timestamp.acs.microsoft.com" ` + # -TimestampDigest SHA256 + # + # - name: Generate checksums for signed Windows binaries + # shell: pwsh + # run: | + # New-Item -ItemType Directory -Force -Path ./dist/assets | Out-Null + # foreach ($arch in @("arm64", "amd64")) { + # foreach ($bin in @("machineid")) { + # $src = "./dist/${bin}-windows-${arch}.exe" + # $zip = "./dist/assets/${bin}-windows-${arch}.zip" + # $tmp = "./dist/zip-${bin}-windows-${arch}" + # New-Item -ItemType Directory -Force -Path $tmp | Out-Null + # Copy-Item $src -Destination "${tmp}/${bin}.exe" + # Compress-Archive -Path "${tmp}/${bin}.exe" -DestinationPath $zip -Force + # Remove-Item -Recurse -Force $tmp + # $hash = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower() + # Set-Content -Path "./dist/assets/${bin}-windows-${arch}.sha256" -Value $hash + # } + # } + # + # - name: Upload signed Windows artifacts + # uses: actions/upload-artifact@v6 + # with: + # name: dist-windows-signed + # path: ./dist/assets/ - rm $ASSETS_NAME + build-sign-macos: + name: Build, Sign & Notarize macOS + needs: test + runs-on: macos-latest + steps: + - name: Check out code + uses: actions/checkout@v6 - mv $TOOL_NAME ~/go/bin/$TOOL_NAME - ~/go/bin/$TOOL_NAME --version + - name: Set up Go 1.x + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod - # go install github.com/boyter/scc/v3@latest + - name: Go version + run: go version - scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + - name: Build macOS binaries (arm64 & amd64) + run: | + GIT_VERSION=${{ github.ref_name }} GO_OS="darwin" make build-dist - - name: Test + - name: Import Apple signing certificate + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} run: | - echo "### Test report" >> $GITHUB_STEP_SUMMARY + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain" - go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12" - - name: Test coverage - run: | - echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$RUNNER_TEMP/certificate.p12" \ + -k "$KEYCHAIN_PATH" \ + -P "$MACOS_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/productsign + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - # 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 + - name: Create universal binaries with lipo + run: | + mkdir -p ./dist/macos + for bin in machineid; do + lipo -create \ + "./dist/${bin}-darwin-arm64" \ + "./dist/${bin}-darwin-amd64" \ + -output "./dist/macos/${bin}" + chmod +x "./dist/macos/${bin}" + done - # 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: Sign universal binaries + env: + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + run: | + for bin in machineid; do + codesign --force \ + --options runtime \ + --sign "$MACOS_SIGNING_IDENTITY" \ + --timestamp \ + "./dist/macos/${bin}" + done - - name: make build-dist + - name: Verify binary signatures run: | - echo "## make build-dist" >> $GITHUB_STEP_SUMMARY - GIT_VERSION=${{ github.ref_name }} make build-dist | tee -a $GITHUB_STEP_SUMMARY + for bin in machineid; do + codesign --verify --verbose=4 "./dist/macos/${bin}" + done - - name: make build-dist-zip + - name: Create, sign, notarize & staple .pkg installers + env: + MACOS_INSTALLER_SIGNING_IDENTITY: ${{ secrets.MACOS_INSTALLER_SIGNING_IDENTITY }} + MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.MACOS_NOTARIZATION_APPLE_ID }} + MACOS_NOTARIZATION_PASSWORD: ${{ secrets.MACOS_NOTARIZATION_PASSWORD }} + MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.MACOS_NOTARIZATION_TEAM_ID }} run: | - echo "## make build-dist-zip" >> $GITHUB_STEP_SUMMARY - GIT_VERSION=${{ github.ref_name }} make build-dist-zip | tee -a $GITHUB_STEP_SUMMARY + for bin in machineid; do + pkg="./dist/assets/${bin}-darwin-universal.pkg" - - name: Upload Distribution files to release - uses: softprops/action-gh-release@v2 + echo "📦 Creating .pkg for ${bin}..." + PKG_APP_NAME="${bin}" GIT_VERSION=${{ github.ref_name }} make build-dist-pkg + + echo "🔏 Signing ${bin} .pkg..." + productsign \ + --sign "$MACOS_INSTALLER_SIGNING_IDENTITY" \ + "${pkg}" \ + "${pkg}.signed" + mv "${pkg}.signed" "${pkg}" + + echo "📤 Notarizing ${bin} .pkg..." + xcrun notarytool submit \ + "${pkg}" \ + --apple-id "$MACOS_NOTARIZATION_APPLE_ID" \ + --password "$MACOS_NOTARIZATION_PASSWORD" \ + --team-id "$MACOS_NOTARIZATION_TEAM_ID" \ + --wait + + echo "📎 Stapling ${bin} .pkg..." + xcrun stapler staple "${pkg}" + xcrun stapler validate "${pkg}" + + echo "🔢 Generating ${bin} .pkg checksum..." + shasum -a 256 "${pkg}" \ + | cut -d ' ' -f 1 > "./dist/assets/${bin}-darwin-universal.sha256" + done + + - name: Clean up keychain + if: always() + run: security delete-keychain "$RUNNER_TEMP/build.keychain" || true + + - name: Upload macOS pkg artifacts + uses: actions/upload-artifact@v6 with: - tag_name: ${{ github.ref_name }} - files: | - dist/assets/*.zip + name: dist-macos + path: ./dist/assets/ + + create-github-release: + name: Create GitHub Release + # needs: [build-linux, build-sign-windows, build-sign-macos] + needs: [build-linux, build-sign-macos] + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v6 - - name: Release + - name: Set up Go 1.x + id: go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + + - name: Go version + run: go version + + - name: Download Linux distribution files + uses: actions/download-artifact@v7 + with: + name: dist-linux + path: ./dist/ + + # - name: Download signed Windows artifacts + # uses: actions/download-artifact@v7 + # with: + # name: dist-windows-signed + # path: ./dist/assets/ + + - name: Download macOS pkg + uses: actions/download-artifact@v7 + with: + name: dist-macos + path: ./dist/assets/ + + - name: Zip Linux binaries + run: | + GIT_VERSION=${{ github.ref_name }} GO_OS="linux" make build-dist-zip + + - name: Copy Linux Sigstore bundles to release assets + run: | + cp ./dist/*.sigstore.json ./dist/assets/ + + - name: Create GitHub Release + id: create-github-release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} @@ -119,4 +347,6 @@ jobs: draft: false prerelease: false generate_release_notes: true - make_latest: true + token: ${{ secrets.GITHUB_TOKEN }} + files: | + dist/assets/** diff --git a/.gitignore b/.gitignore index 540bf58..84b6044 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,8 @@ go.work.sum # .idea/ # .vscode/ -machineid +# Ignore the compiled binary at the root, not the installer/macos/machineid/ directory +/machineid .DS_Store build/ dist/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e9da3ca..a02a5cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "betteralign", + "codesign", "CPUID", "csproduct", "diskdrive", @@ -8,11 +9,17 @@ "golangci", "govulncheck", "ioreg", + "lipo", "machdep", "machineid", + "notarytool", "OSCPU", + "productsign", + "Sigstore", "slashdevops", + "softprops", "UEFI", - "vulncheck" + "vulncheck", + "xcrun" ] } \ No newline at end of file diff --git a/Makefile b/Makefile index 5c6d637..5c84e40 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,11 @@ $(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 && printf " 🤞 ${1} ✅\n" || (printf " ${1} ❌ 🖕\n"; exit 1) \ + , \ + @${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 \ @@ -198,20 +202,48 @@ build-dist: ## Build the application for all platforms defined in GO_OS and GO_A ) .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 +build-dist-zip: ## Create zip files with clean binary names (no OS/arch suffix inside the zip). 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, cp $(DIST_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH) $(DIST_DIR)/$(proj_mod) ) \ - $(call exec_cmd, zip --junk-paths -r $(DIST_ASSETS_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH).zip $(DIST_DIR)/$(proj_mod) ) \ - $(call exec_cmd, rm $(DIST_DIR)/$(proj_mod) ) \ + $(call exec_cmd, mkdir -p $(DIST_DIR)/zip-$(proj_mod)-$(GOOS)-$(GOARCH)) \ + $(call exec_cmd, cp $(DIST_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH) $(DIST_DIR)/zip-$(proj_mod)-$(GOOS)-$(GOARCH)/$(proj_mod)$(if $(filter windows,$(GOOS)),.exe,)) \ + $(call exec_cmd, zip --junk-paths -r $(DIST_ASSETS_DIR)/$(proj_mod)-$(GOOS)-$(GOARCH).zip $(DIST_DIR)/zip-$(proj_mod)-$(GOOS)-$(GOARCH)/) \ + $(call exec_cmd, rm -rf $(DIST_DIR)/zip-$(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 ) \ ) \ ) \ ) +INSTALLER_DIR := ./installer/macos + +.PHONY: build-dist-pkg +build-dist-pkg: ## Create a macOS universal .pkg installer with license and readme. Requires PKG_APP_NAME and macOS. Usage: PKG_APP_NAME=machineid make build-dist-pkg +ifndef PKG_APP_NAME + $(error PKG_APP_NAME is required. Usage: PKG_APP_NAME=machineid make build-dist-pkg) +endif + @printf "👉 Creating macOS .pkg for $(PKG_APP_NAME)...\n" + $(call exec_cmd, mkdir -p $(DIST_ASSETS_DIR)) + $(call exec_cmd, mkdir -p $(DIST_DIR)/macos-pkg-$(PKG_APP_NAME)/usr/local/bin) + $(call exec_cmd, cp $(DIST_DIR)/macos/$(PKG_APP_NAME) $(DIST_DIR)/macos-pkg-$(PKG_APP_NAME)/usr/local/bin/$(PKG_APP_NAME)) + $(call exec_cmd, pkgbuild \ + --root $(DIST_DIR)/macos-pkg-$(PKG_APP_NAME) \ + --identifier "com.slashdevops.$(PKG_APP_NAME)" \ + --version "$(GIT_VERSION)" \ + --install-location "/" \ + "$(DIST_DIR)/$(PKG_APP_NAME)-component.pkg" \ + ) + $(call exec_cmd, productbuild \ + --distribution $(INSTALLER_DIR)/$(PKG_APP_NAME)/distribution.xml \ + --resources $(INSTALLER_DIR)/$(PKG_APP_NAME)/resources \ + --package-path $(DIST_DIR) \ + "$(DIST_ASSETS_DIR)/$(PKG_APP_NAME)-darwin-universal.pkg" \ + ) + $(call exec_cmd, rm -f $(DIST_DIR)/$(PKG_APP_NAME)-component.pkg) + $(call exec_cmd, shasum -a 256 $(DIST_ASSETS_DIR)/$(PKG_APP_NAME)-darwin-universal.pkg | cut -d ' ' -f 1 > $(DIST_ASSETS_DIR)/$(PKG_APP_NAME)-darwin-universal.sha256) + ############################################################################### ##@ Check commands .PHONY: lint diff --git a/README.md b/README.md index 9df85f7..bcb8ee3 100644 --- a/README.md +++ b/README.md @@ -66,32 +66,33 @@ source ~/.zshrc #### Installing a Precompiled Binary -Precompiled binaries for macOS, Linux, and Windows are available on the [releases page](https://github.com/slashdevops/machineid/releases). +Signed and notarized binaries for macOS, Linux, and Windows are available on the [releases page](https://github.com/slashdevops/machineid/releases). -You can download them with the [GitHub CLI](https://cli.github.com/manual/installation) (`gh`): +**macOS** (signed & notarized universal `.pkg` installer — arm64 + amd64): ```bash -brew install gh # if not already installed +curl -L https://github.com/slashdevops/machineid/releases/latest/download/machineid-darwin-universal.pkg -o machineid.pkg +sudo installer -pkg machineid.pkg -target / ``` -Then fetch and install the binary: +Or double-click the `.pkg` file in Finder to use the graphical installer. + +**Linux**: ```bash -export TOOL_NAME="machineid" -export GIT_ORG="slashdevops" -export GIT_REPO="machineid" -export OS=$(uname -s | tr '[:upper:]' '[:lower:]') -export OS_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') -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 -unzip $ASSETS_NAME -rm $ASSETS_NAME - -mv $TOOL_NAME ~/go/bin/$TOOL_NAME -~/go/bin/$TOOL_NAME -version +curl -L https://github.com/slashdevops/machineid/releases/latest/download/machineid-linux-amd64.zip -o machineid.zip +unzip machineid.zip && sudo mv machineid /usr/local/bin/ +``` + +**Windows** (via PowerShell): + +```powershell +Invoke-WebRequest -Uri https://github.com/slashdevops/machineid/releases/latest/download/machineid-windows-amd64.zip -OutFile machineid.zip +Expand-Archive machineid.zip -DestinationPath $env:USERPROFILE\bin ``` +See [docs/macos-signing.md](docs/macos-signing.md) and [docs/linux-signing.md](docs/linux-signing.md) for details on binary verification. + #### Building from Source Clone the repository and build with version metadata via the provided Makefile: @@ -344,7 +345,10 @@ See the [Installation](#installation) section above for all ways to install the ### Examples ```bash -# Generate an ID from CPU + UUID (default 64 chars) +# Default: CPU + motherboard + UUID (64 hex chars) +machineid + +# Specific components machineid -cpu -uuid # All hardware sources, compact 32-char format @@ -354,49 +358,48 @@ machineid -all -format 32 machineid -vm -salt "my-app" # JSON output with diagnostics -machineid -cpu -uuid -json -diagnostics +machineid -all -json -diagnostics # Validate a previously stored ID machineid -cpu -uuid -validate "b5c42832542981af58c9dc3bc241219e780ff7d276cfad05fac222846edb84f7" -# Info-level logging (fallbacks, lifecycle events) -machineid -cpu -uuid -verbose - -# Include only physical MACs (default) -machineid -mac -mac-filter physical - # Include all MACs (physical + virtual) -machineid -all -mac-filter all +machineid -mac -mac-filter all + +# Info-level logging (fallbacks, lifecycle events) +machineid -all -verbose # Debug-level logging (command details, raw values, timing) machineid -all -debug # Version information machineid -version -machineid -version.long +machineid -version-long ``` ### All Flags -| Flag | Description | -|-----------------|-----------------------------------------------------------------| -| `-cpu` | Include CPU identifier | -| `-motherboard` | Include motherboard serial number | -| `-uuid` | Include system UUID | -| `-mac` | Include network MAC addresses | -| `-mac-filter F` | MAC filter: `physical` (default), `all`, or `virtual` | -| `-disk` | Include disk serial numbers | -| `-all` | Include all hardware identifiers | -| `-vm` | VM-friendly mode (CPU + UUID only) | -| `-format N` | Output length: `32`, `64` (default), `128`, or `256` | -| `-salt STRING` | Custom salt for application-specific IDs | -| `-validate ID` | Validate an ID against the current machine | -| `-diagnostics` | Show collected/failed components | -| `-json` | Output as JSON | -| `-verbose` | Enable info-level logging to stderr (fallbacks, lifecycle) | -| `-debug` | Enable debug-level logging to stderr (commands, values, timing) | -| `-version` | Show version information | -| `-version.long` | Show detailed version information | +| Flag | Description | +|------------------|---------------------------------------------------------------------| +| `-cpu` | Include CPU identifier | +| `-motherboard` | Include motherboard serial number | +| `-uuid` | Include system UUID (BIOS/UEFI) | +| `-mac` | Include network interface MAC addresses | +| `-mac-filter F` | MAC filter: `physical` (default), `all`, or `virtual` | +| `-disk` | Include disk serial numbers | +| `-all` | Include all hardware identifiers (CPU, motherboard, UUID, MAC, disk)| +| `-vm` | VM-friendly mode: CPU + UUID only | +| `-format N` | Output length: `32`, `64` (default), `128`, or `256` hex chars | +| `-salt STRING` | Application-specific salt for unique IDs per app | +| `-validate ID` | Check a stored ID against the current machine | +| `-diagnostics` | Show which hardware components were collected or failed | +| `-json` | Format output as JSON | +| `-verbose` | Info-level logs to stderr (fallbacks, lifecycle) | +| `-debug` | Debug-level logs to stderr (commands, values, timing) | +| `-version` | Print version and exit | +| `-version-long` | Print detailed build information and exit | + +When no component flags are specified, the default is `-cpu -motherboard -uuid`. ## How It Works @@ -415,6 +418,8 @@ machineid -version.long Each source has fallback methods for resilience across OS versions and configurations. +> **Performance note**: On Windows, all hardware queries run **concurrently** using goroutines. This reduces total latency from the sum of all `wmic`/PowerShell calls (which are slow due to process startup overhead) to the maximum of any single call — typically cutting ID generation time from ~8-12s to ~2-3s. + ## Testing The library supports dependency injection for deterministic testing without real system commands: @@ -505,11 +510,13 @@ error: failed to push some refs to 'github.com:slashdevops/machineid.git' **Solution**: Create a tag with a version number higher than all existing tags. 1. Check existing tags: + ```bash git tag -l ``` 2. Create the next appropriate version: + ```bash # If the latest tag is v0.0.2, use v0.0.3 or higher git tag -a "v0.0.3" -m "Release v0.0.3" diff --git a/cmd/machineid/main.go b/cmd/machineid/main.go index fef88bb..342c82b 100644 --- a/cmd/machineid/main.go +++ b/cmd/machineid/main.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "os" + "runtime" "runtime/debug" "strings" @@ -20,99 +21,49 @@ 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") - macFilterFlag := flag.String("mac-filter", "physical", "MAC filter: physical, all, virtual") + uuid := flag.Bool("uuid", false, "Include system UUID (BIOS/UEFI)") + mac := flag.Bool("mac", false, "Include network interface MAC addresses") + macFilterFlag := flag.String("mac-filter", "physical", "MAC address filter: physical, all, or virtual (requires -mac or -all)") 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)") + all := flag.Bool("all", false, "Include all hardware identifiers (CPU, motherboard, UUID, MAC, disk)") + vm := flag.Bool("vm", false, "VM-friendly mode: use only CPU + UUID (ignores other component flags)") // 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") + format := flag.Int("format", 64, "Output length in hex characters: 32, 64, 128, or 256") + salt := flag.String("salt", "", "Application-specific salt to produce unique IDs per app") // 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") + validate := flag.String("validate", "", "Validate a previously stored ID against this machine") + diagnostics := flag.Bool("diagnostics", false, "Show which hardware components were collected or failed") + jsonOutput := flag.Bool("json", false, "Format output as JSON") // Logging flags - verbose := flag.Bool("verbose", false, "Enable info-level logging to stderr (fallbacks, lifecycle)") - debugFlag := flag.Bool("debug", false, "Enable debug-level logging to stderr (command details, raw values, timing)") + verbose := flag.Bool("verbose", false, "Log info-level messages to stderr (fallbacks, lifecycle events)") + debugFlag := flag.Bool("debug", false, "Log debug-level messages to stderr (commands, raw values, timing)") // 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 -cpu -uuid -verbose Show info-level logs\n") - fmt.Fprintf(os.Stderr, " machineid -all -debug Show debug-level logs\n") - fmt.Fprintf(os.Stderr, " machineid -version Show version\n") - fmt.Fprintf(os.Stderr, " machineid -version.long Show detailed version\n") - } + versionFlag := flag.Bool("version", false, "Print version and exit") + versionLongFlag := flag.Bool("version-long", false, "Print detailed build information and exit") + + flag.Usage = printUsage 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) - } - + printVersion() 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()) + printVersionLong() os.Exit(0) } formatMode, err := parseFormatMode(*format) if err != nil { - slog.Error("invalid format", "error", err) - flag.Usage() - os.Exit(1) + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + printUsage() + os.Exit(2) } // Configure logger @@ -137,9 +88,9 @@ func main() { mFilter, err := parseMACFilter(*macFilterFlag) if err != nil { - slog.Error("invalid mac-filter", "error", err) - flag.Usage() - os.Exit(1) + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + printUsage() + os.Exit(2) } switch { @@ -175,7 +126,7 @@ func main() { id, err := provider.ID(ctx) if err != nil { - slog.Error("failed to generate machine ID", "error", err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -192,6 +143,9 @@ func main() { "format": *format, "length": len(id), } + if *salt != "" { + output["salt"] = *salt + } if *diagnostics { output["diagnostics"] = formatDiagnostics(provider) } @@ -206,6 +160,123 @@ func main() { } } +func printUsage() { + w := os.Stderr + fmt.Fprintf(w, "Generate unique, deterministic machine identifiers from hardware characteristics.\n\n") + fmt.Fprintf(w, "Usage:\n") + fmt.Fprintf(w, " %s [-cpu] [-uuid] [-motherboard] [-mac] [-disk] [options]\n", applicationName) + fmt.Fprintf(w, " %s -all [options]\n", applicationName) + fmt.Fprintf(w, " %s -vm [options]\n\n", applicationName) + + fmt.Fprintf(w, "When no component flags are specified, the default is -cpu -motherboard -uuid.\n\n") + + fmt.Fprintf(w, "Hardware Components:\n") + printFlag(w, "-cpu", "Include CPU identifier") + printFlag(w, "-motherboard", "Include motherboard serial number") + printFlag(w, "-uuid", "Include system UUID (BIOS/UEFI)") + printFlag(w, "-mac", "Include network interface MAC addresses") + printFlag(w, "-disk", "Include disk serial numbers") + printFlag(w, "-all", "Include all hardware identifiers") + printFlag(w, "-vm", "VM-friendly mode: CPU + UUID only") + fmt.Fprintln(w) + + fmt.Fprintf(w, "Output Options:\n") + printFlag(w, "-format N", "Output length: 32, 64 (default), 128, or 256 hex chars") + printFlag(w, "-salt STRING", "Application-specific salt for unique IDs per app") + printFlag(w, "-mac-filter F", "MAC filter: physical (default), all, or virtual") + printFlag(w, "-json", "Format output as JSON") + printFlag(w, "-diagnostics", "Show collected/failed hardware components") + fmt.Fprintln(w) + + fmt.Fprintf(w, "Validation:\n") + printFlag(w, "-validate ID", "Check a stored ID against the current machine") + fmt.Fprintln(w) + + fmt.Fprintf(w, "Logging:\n") + printFlag(w, "-verbose", "Info-level logs to stderr (fallbacks, lifecycle)") + printFlag(w, "-debug", "Debug-level logs to stderr (commands, values, timing)") + fmt.Fprintln(w) + + fmt.Fprintf(w, "Info:\n") + printFlag(w, "-version", "Print version and exit") + printFlag(w, "-version-long", "Print detailed build information and exit") + fmt.Fprintln(w) + + fmt.Fprintf(w, "Examples:\n") + fmt.Fprintf(w, " %s Default: CPU + motherboard + UUID\n", applicationName) + fmt.Fprintf(w, " %s -cpu -uuid Specific components\n", applicationName) + fmt.Fprintf(w, " %s -all -format 32 All hardware, compact output\n", applicationName) + fmt.Fprintf(w, " %s -vm -salt \"my-app\" VM-friendly with salt\n", applicationName) + fmt.Fprintf(w, " %s -all -json -diagnostics JSON with diagnostics\n", applicationName) + fmt.Fprintf(w, " %s -cpu -uuid -validate Validate a stored ID\n", applicationName) + fmt.Fprintf(w, " %s -mac -mac-filter all Include all MAC addresses\n", applicationName) + fmt.Fprintf(w, " %s -all -verbose Show info-level logs\n", applicationName) + fmt.Fprintf(w, " %s -all -debug Show debug-level logs\n", applicationName) + fmt.Fprintln(w) + + fmt.Fprintf(w, "Exit Codes:\n") + fmt.Fprintf(w, " 0 Success\n") + fmt.Fprintf(w, " 1 ID generation or validation failed\n") + fmt.Fprintf(w, " 2 Invalid arguments\n") +} + +func printFlag(w *os.File, name, desc string) { + fmt.Fprintf(w, " %-20s %s\n", name, desc) +} + +func printVersion() { + v := resolveVersion() + fmt.Printf("%s %s\n", applicationName, v) +} + +func printVersionLong() { + v := resolveVersion() + fmt.Printf("%s %s\n", applicationName, v) + + if version.Version != "0.0.0" { + printField("Build date", version.BuildDate) + printField("Git commit", version.GitCommit) + printField("Git branch", version.GitBranch) + printField("Build user", version.BuildUser) + printField("Go version", version.GoVersion) + printField("Platform", fmt.Sprintf("%s/%s", version.GoVersionOS, version.GoVersionArch)) + } else if info, ok := debug.ReadBuildInfo(); ok { + printField("Module", info.Main.Path) + printField("Go version", info.GoVersion) + printField("Platform", fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)) + + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + printField("Git commit", setting.Value) + case "vcs.time": + printField("Commit date", setting.Value) + case "vcs.modified": + if setting.Value == "true" { + printField("Modified", "yes (uncommitted changes)") + } + } + } + } +} + +func resolveVersion() string { + if version.Version != "0.0.0" { + return version.Version + } + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" { + return info.Main.Version + } + return "devel" +} + +func printField(label, value string) { + if value == "" { + return + } + fmt.Printf(" %-14s %s\n", label+":", value) +} + func parseFormatMode(format int) (machineid.FormatMode, error) { switch format { case 32: @@ -237,7 +308,7 @@ func parseMACFilter(value string) (machineid.MACFilter, error) { 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) + fmt.Fprintf(os.Stderr, "Error: validation failed: %v\n", err) os.Exit(1) } @@ -304,7 +375,7 @@ 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) + fmt.Fprintf(os.Stderr, "Error: failed to encode JSON: %v\n", err) os.Exit(1) } } diff --git a/cmd/machineid/main_test.go b/cmd/machineid/main_test.go index da0c83c..4d72866 100644 --- a/cmd/machineid/main_test.go +++ b/cmd/machineid/main_test.go @@ -38,6 +38,35 @@ func TestParseFormatMode(t *testing.T) { } } +func TestParseMACFilter(t *testing.T) { + tests := []struct { + input string + want machineid.MACFilter + wantErr bool + }{ + {"physical", machineid.MACFilterPhysical, false}, + {"Physical", machineid.MACFilterPhysical, false}, + {"PHYSICAL", machineid.MACFilterPhysical, false}, + {"all", machineid.MACFilterAll, false}, + {"ALL", machineid.MACFilterAll, false}, + {"virtual", machineid.MACFilterVirtual, false}, + {"Virtual", machineid.MACFilterVirtual, false}, + {"invalid", 0, true}, + {"", 0, true}, + } + + for _, tt := range tests { + got, err := parseMACFilter(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseMACFilter(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("parseMACFilter(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + func TestFormatDiagnosticsNil(t *testing.T) { provider := machineid.New() // Before ID() call, Diagnostics() is nil @@ -83,7 +112,9 @@ func TestPrintDiagnosticsWithData(t *testing.T) { func TestFormatDiagnosticsWithErrors(t *testing.T) { provider := machineid.New().WithCPU().WithDisk() - _, _ = provider.ID(t.Context()) + if _, err := provider.ID(t.Context()); err != nil { + t.Logf("ID() error (may be expected): %v", err) + } result := formatDiagnostics(provider) if result == nil { @@ -99,7 +130,10 @@ func TestFormatDiagnosticsWithErrors(t *testing.T) { func TestPrintJSON(t *testing.T) { // Capture stdout oldStdout := os.Stdout - r, w, _ := os.Pipe() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error: %v", err) + } os.Stdout = w printJSON(map[string]any{"key": "value"}) @@ -108,7 +142,9 @@ func TestPrintJSON(t *testing.T) { os.Stdout = oldStdout var buf bytes.Buffer - io.Copy(&buf, r) + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy error: %v", err) + } var result map[string]any if err := json.Unmarshal(buf.Bytes(), &result); err != nil { @@ -128,7 +164,10 @@ func TestHandleValidateValid(t *testing.T) { // Capture stdout oldStdout := os.Stdout - r, w, _ := os.Pipe() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error: %v", err) + } os.Stdout = w handleValidate(t.Context(), provider, id, false) @@ -137,7 +176,9 @@ func TestHandleValidateValid(t *testing.T) { os.Stdout = oldStdout var buf bytes.Buffer - io.Copy(&buf, r) + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy error: %v", err) + } if !bytes.Contains(buf.Bytes(), []byte("valid: machine ID matches")) { t.Errorf("Expected 'valid: machine ID matches', got %q", buf.String()) @@ -153,7 +194,10 @@ func TestHandleValidateValidJSON(t *testing.T) { // Capture stdout oldStdout := os.Stdout - r, w, _ := os.Pipe() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error: %v", err) + } os.Stdout = w handleValidate(t.Context(), provider, id, true) @@ -162,7 +206,9 @@ func TestHandleValidateValidJSON(t *testing.T) { os.Stdout = oldStdout var buf bytes.Buffer - io.Copy(&buf, r) + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy error: %v", err) + } var result map[string]any if err := json.Unmarshal(buf.Bytes(), &result); err != nil { @@ -172,3 +218,92 @@ func TestHandleValidateValidJSON(t *testing.T) { t.Errorf("Expected valid=true, got %v", result["valid"]) } } + +func TestResolveVersion(t *testing.T) { + v := resolveVersion() + if v == "" { + t.Error("resolveVersion() returned empty string") + } + // In test environment without ldflags, should return "devel" or a module version + t.Logf("resolveVersion() = %q", v) +} + +func TestPrintFlag(t *testing.T) { + // Capture stderr + oldStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error: %v", err) + } + os.Stderr = w + + printFlag(w, "-cpu", "Include CPU identifier") + + w.Close() + os.Stderr = oldStderr + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy error: %v", err) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("-cpu")) { + t.Errorf("Expected '-cpu' in output, got %q", output) + } + if !bytes.Contains([]byte(output), []byte("Include CPU identifier")) { + t.Errorf("Expected description in output, got %q", output) + } +} + +func TestPrintField(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error: %v", err) + } + os.Stdout = w + + printField("Go version", "go1.25.0") + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy error: %v", err) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("Go version:")) { + t.Errorf("Expected 'Go version:' in output, got %q", output) + } + if !bytes.Contains([]byte(output), []byte("go1.25.0")) { + t.Errorf("Expected 'go1.25.0' in output, got %q", output) + } +} + +func TestPrintFieldEmpty(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error: %v", err) + } + os.Stdout = w + + printField("Empty", "") + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy error: %v", err) + } + + if buf.Len() != 0 { + t.Errorf("Expected no output for empty value, got %q", buf.String()) + } +} diff --git a/doc.go b/doc.go index ca10350..6b7e0b7 100644 --- a/doc.go +++ b/doc.go @@ -156,8 +156,15 @@ // # 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. +// platform uses native tools to collect hardware data: +// +// - macOS: system_profiler, ioreg, sysctl +// - Linux: /proc/cpuinfo, /sys/class/dmi/id, /etc/machine-id, lsblk, /sys/block +// - Windows: wmic, PowerShell (Get-CimInstance) — collected concurrently +// +// On Windows, all hardware queries run in parallel using goroutines to +// minimize latency from slow process startup (wmic and PowerShell). Each +// command uses wmic as the primary method with PowerShell as fallback. // // # Installation // @@ -174,14 +181,16 @@ // // # 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 -mac -mac-filter all -// machineid -cpu -uuid -verbose -// machineid -all -debug -// machineid -version -// machineid -version.long +// A ready-to-use command-line tool is provided in cmd/machineid. +// When no component flags are specified, the default is -cpu -motherboard -uuid. +// +// machineid # default: CPU + motherboard + UUID +// machineid -cpu -uuid # specific components +// machineid -all -format 32 -json # all hardware, compact JSON +// machineid -vm -salt "my-app" # VM-friendly with salt +// machineid -mac -mac-filter all # include all MAC addresses +// machineid -all -verbose # info-level logs +// machineid -all -debug # debug-level logs +// machineid -version # version info +// machineid -version-long # detailed build info package machineid diff --git a/docs/linux-signing.md b/docs/linux-signing.md new file mode 100644 index 0000000..329ef61 --- /dev/null +++ b/docs/linux-signing.md @@ -0,0 +1,44 @@ +# Linux Binary Signing with Sigstore Cosign + +Linux binaries are signed using [Sigstore Cosign](https://docs.sigstore.dev/cosign/overview/) in keyless mode. This provides cryptographic provenance without managing signing keys. + +--- + +## How It Works + +The release workflow signs each Linux binary using **keyless signing** via the Sigstore public infrastructure: + +1. GitHub Actions authenticates via OIDC to Sigstore's Fulcio CA. +2. Cosign generates an ephemeral certificate tied to the GitHub Actions workflow identity. +3. The signature and certificate are bundled into a `.sigstore.json` file. +4. The bundle is uploaded alongside the binary as a release asset. + +No secrets are required — authentication is handled automatically via GitHub's OIDC token. + +--- + +## Verifying a Binary + +Download both the binary zip and its `.sigstore.json` bundle from the [releases page](https://github.com/slashdevops/machineid/releases), then verify: + +```bash +# Install cosign +go install github.com/sigstore/cosign/v2/cmd/cosign@latest + +# Verify the binary against its Sigstore bundle +cosign verify-blob \ + --bundle machineid-linux-amd64.sigstore.json \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp "github.com/slashdevops/machineid" \ + machineid-linux-amd64 +``` + +A successful verification confirms: +- The binary was built by the `slashdevops/machineid` GitHub Actions workflow. +- The binary has not been tampered with since signing. + +--- + +## Workflow Permissions + +The release workflow requires `id-token: write` permission to request an OIDC token from GitHub for Sigstore authentication. This is already configured in the workflow. diff --git a/docs/macos-signing.md b/docs/macos-signing.md new file mode 100644 index 0000000..2d79c4e --- /dev/null +++ b/docs/macos-signing.md @@ -0,0 +1,131 @@ +# macOS Code Signing & Notarization + +This document covers every secret, certificate, and piece of configuration required to produce signed and notarized macOS `.pkg` installers through the release workflow. + +--- + +## Prerequisites + +| Requirement | Details | +| --- | --- | +| Apple Developer Program membership | Paid individual or organization account at [developer.apple.com](https://developer.apple.com) | +| Certificate: Developer ID Application | Required for code-signing the binaries with `codesign` | +| Certificate: Developer ID Installer | Required for signing the `.pkg` installers with `productsign` | +| Xcode Command Line Tools | Pre-installed on GitHub's `macos-latest` runner | + +> **Important**: You need **two** Developer ID certificates — one for binaries (`Application`) and one for installers (`Installer`). Both should be exported into a single `.p12` file. + +### Creating the certificates + +If you don't already have both certificates: + +1. Go to [developer.apple.com/account/resources/certificates](https://developer.apple.com/account/resources/certificates). +2. Click **+** to create a new certificate. +3. Under **Software**, select **Developer ID Application** and follow the CSR steps. Download and install it. +4. Click **+** again, select **Developer ID Installer**, and repeat. Download and install it. +5. Both certificates should now appear in **Keychain Access** under **My Certificates**. + +--- + +## Secrets Reference + +Add all of the following in your GitHub repository under **Settings > Secrets and variables > Actions > Repository secrets**. + +### `MACOS_CERTIFICATE` + +Base64-encoded `.p12` certificate file containing **both** the Developer ID Application and Developer ID Installer certificates with their private keys. + +**How to export and encode:** + +1. Open **Keychain Access** on your Mac. +2. Select both certificates: + - **Developer ID Application: Your Name (XXXXXXXXXX)** + - **Developer ID Installer: Your Name (XXXXXXXXXX)** +3. Right-click > **Export 2 items** > choose `.p12` format > save as `certificate.p12`. +4. Choose a strong export password (this becomes `MACOS_CERTIFICATE_PASSWORD`). +5. Encode it: + + ```bash + base64 -i certificate.p12 | pbcopy + ``` + +6. Paste the clipboard contents as the secret value. + +### `MACOS_CERTIFICATE_PASSWORD` + +The password you chose when exporting the `.p12` from Keychain Access. + +### `MACOS_SIGNING_IDENTITY` + +The exact **Developer ID Application** signing identity string for binary code-signing. + +```bash +security find-identity -v -p codesigning +``` + +Example: `Developer ID Application: Acme Corp (AB12CD34EF)` + +### `MACOS_INSTALLER_SIGNING_IDENTITY` + +The exact **Developer ID Installer** signing identity string for `.pkg` signing. + +```bash +security find-identity -v +``` + +Example: `Developer ID Installer: Acme Corp (AB12CD34EF)` + +### `MACOS_NOTARIZATION_APPLE_ID` + +The Apple ID (email) associated with your Apple Developer Program account. + +### `MACOS_NOTARIZATION_PASSWORD` + +An **app-specific password** — not your Apple ID password. Generate at [appleid.apple.com](https://appleid.apple.com) > Sign-In and Security > App-Specific Passwords. + +### `MACOS_NOTARIZATION_TEAM_ID` + +Your 10-character Apple Developer Team ID from [developer.apple.com/account](https://developer.apple.com/account) > Membership Details. + +--- + +## Summary Table + +| Secret name | Example value | Where to get it | +| --- | --- | --- | +| `MACOS_CERTIFICATE` | `MIIKxAIBAzCC...` (base64) | Keychain Access > Export both certs > `base64 -i cert.p12` | +| `MACOS_CERTIFICATE_PASSWORD` | `MyStr0ngP@ss!` | Password chosen during `.p12` export | +| `MACOS_SIGNING_IDENTITY` | `Developer ID Application: Acme Corp (AB12CD34EF)` | `security find-identity -v -p codesigning` | +| `MACOS_INSTALLER_SIGNING_IDENTITY` | `Developer ID Installer: Acme Corp (AB12CD34EF)` | `security find-identity -v` | +| `MACOS_NOTARIZATION_APPLE_ID` | `dev@example.com` | Apple ID email | +| `MACOS_NOTARIZATION_PASSWORD` | `xxxx-xxxx-xxxx-xxxx` | appleid.apple.com > App-Specific Passwords | +| `MACOS_NOTARIZATION_TEAM_ID` | `AB12CD34EF` | developer.apple.com > Membership Details | + +--- + +## Set Secrets with GitHub CLI + +```bash +gh secret set MACOS_CERTIFICATE --repo slashdevops/machineid --body "$(base64 -i certificate.p12)" +gh secret set MACOS_CERTIFICATE_PASSWORD --repo slashdevops/machineid +gh secret set MACOS_SIGNING_IDENTITY --repo slashdevops/machineid --body "Developer ID Application: Your Name (TEAMID)" +gh secret set MACOS_INSTALLER_SIGNING_IDENTITY --repo slashdevops/machineid --body "Developer ID Installer: Your Name (TEAMID)" +gh secret set MACOS_NOTARIZATION_APPLE_ID --repo slashdevops/machineid --body "yourname@example.com" +gh secret set MACOS_NOTARIZATION_PASSWORD --repo slashdevops/machineid +gh secret set MACOS_NOTARIZATION_TEAM_ID --repo slashdevops/machineid --body "AB12CD34EF" +``` + +--- + +## What the CI Does With These Secrets + +1. Decodes `MACOS_CERTIFICATE` into a temporary `.p12` file. +2. Creates an ephemeral keychain on the runner (deleted after the job, via `if: always()`). +3. Imports the certificates into that keychain and grants `codesign` and `productsign` access. +4. Builds darwin arm64 + amd64 binaries, merges them into a universal binary with `lipo`. +5. Signs the binary with `codesign --options runtime --timestamp` (hardened runtime required for notarization). +6. Creates a `.pkg` installer using `pkgbuild` (installs to `/usr/local/bin`). +7. Signs the `.pkg` with `productsign` using the Developer ID Installer certificate. +8. Submits the `.pkg` to Apple's notarization service via `xcrun notarytool submit --wait`. +9. Staples the notarization ticket to the `.pkg` with `xcrun stapler staple`. +10. Uploads the `.pkg` as a GitHub Release asset. diff --git a/errors.go b/errors.go index c8a98af..79add00 100644 --- a/errors.go +++ b/errors.go @@ -35,8 +35,8 @@ var ( // CommandError records a failed system command execution. // Use [errors.As] to extract the command name from wrapped errors. type CommandError struct { - Command string // command name, e.g. "sysctl", "ioreg", "wmic" Err error // underlying error from exec + Command string // command name, e.g. "sysctl", "ioreg", "wmic" } // Error returns a human-readable description of the command failure. @@ -52,8 +52,8 @@ func (e *CommandError) Unwrap() error { // ParseError records a failure while parsing command or system output. // Use [errors.As] to extract the source from wrapped errors. type ParseError struct { - Source string // data source, e.g. "system_profiler JSON", "wmic output" Err error // underlying parse error + Source string // data source, e.g. "system_profiler JSON", "wmic output" } // Error returns a human-readable description of the parse failure. @@ -69,8 +69,8 @@ func (e *ParseError) Unwrap() error { // ComponentError records a failure while collecting a specific hardware component. // These errors appear in [DiagnosticInfo.Errors] and can be inspected with [errors.As]. type ComponentError struct { - Component string // component name, e.g. "cpu", "uuid", "disk" Err error // underlying error + Component string // component name, e.g. "cpu", "uuid", "disk" } // Error returns a human-readable description of the component failure. diff --git a/example_darwin_test.go b/example_darwin_test.go index 7461a5a..2552b11 100644 --- a/example_darwin_test.go +++ b/example_darwin_test.go @@ -16,7 +16,8 @@ func ExampleProvider_Diagnostics() { WithCPU(). WithSystemUUID() - _, _ = provider.ID(context.Background()) + //nolint:errcheck // Example: error handling omitted for brevity + provider.ID(context.Background()) diag := provider.Diagnostics() if diag == nil { @@ -39,13 +40,13 @@ func Example_integrity() { 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()) + id1, _ := p1.ID(context.Background()) //nolint:errcheck // Example + id2, _ := p2.ID(context.Background()) //nolint:errcheck // Example + id3, _ := p3.ID(context.Background()) //nolint:errcheck // Example + id4, _ := p4.ID(context.Background()) //nolint:errcheck // Example // Same configuration always produces same ID - id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background()) + id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background()) //nolint:errcheck // Example fmt.Printf("Consistency: %v\n", id1 == id1Again) // Different configurations produce different IDs diff --git a/example_linux_test.go b/example_linux_test.go new file mode 100644 index 0000000..3f1c362 --- /dev/null +++ b/example_linux_test.go @@ -0,0 +1,91 @@ +//go:build linux + +package machineid_test + +import ( + "context" + "fmt" + + "github.com/slashdevops/machineid" +) + +// ExampleProvider_Diagnostics demonstrates inspecting which hardware components +// were successfully collected on Linux. +func ExampleProvider_Diagnostics() { + provider := machineid.New(). + WithCPU(). + WithSystemUUID() + + //nolint:errcheck // Example: error handling omitted for brevity + 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 +} + +// Example_integrity demonstrates that the format maintains integrity without collisions. +func Example_integrity() { + 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()) //nolint:errcheck // Example + id2, _ := p2.ID(context.Background()) //nolint:errcheck // Example + id3, _ := p3.ID(context.Background()) //nolint:errcheck // Example + id4, _ := p4.ID(context.Background()) //nolint:errcheck // Example + + // Same configuration always produces same ID + id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background()) //nolint:errcheck // Example + 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_linuxFileSources shows that Linux reads hardware data from +// filesystem paths rather than spawning external commands. +func Example_linuxFileSources() { + // On Linux, most hardware identifiers are read directly from /proc and /sys: + // CPU: /proc/cpuinfo + // UUID: /sys/class/dmi/id/product_uuid + // Machine ID: /etc/machine-id + // Motherboard: /sys/class/dmi/id/board_serial + // Disk: lsblk + /sys/block/*/device/serial + // + // File reads are fast — no process startup overhead. + provider := machineid.New(). + WithCPU(). + WithSystemUUID(). + WithDisk() + + 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 +} diff --git a/example_poweroftwo_test.go b/example_poweroftwo_test.go index 4f8c982..da014a1 100644 --- a/example_poweroftwo_test.go +++ b/example_poweroftwo_test.go @@ -11,12 +11,12 @@ import ( // 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()) + id32, _ := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format32).ID(context.Background()) //nolint:errcheck // Example 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()) + id64, _ := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format64).ID(context.Background()) //nolint:errcheck // Example 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) diff --git a/example_test.go b/example_test.go index 9455fc5..c0fe500 100644 --- a/example_test.go +++ b/example_test.go @@ -28,17 +28,17 @@ func ExampleNew() { 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) + id1, _ := machineid.New(). //nolint:errcheck // Example + WithCPU(). + WithSystemUUID(). + WithSalt("app-one"). + ID(ctx) + + id2, _ := machineid.New(). //nolint:errcheck // Example + 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) @@ -79,14 +79,14 @@ func ExampleProvider_Validate() { WithCPU(). WithSystemUUID() - id, _ := provider.ID(context.Background()) + id, _ := provider.ID(context.Background()) //nolint:errcheck // Example // Validate the correct ID - valid, _ := provider.Validate(context.Background(), id) + valid, _ := provider.Validate(context.Background(), id) //nolint:errcheck // Example fmt.Printf("Correct ID valid: %v\n", valid) // Validate an incorrect ID - valid, _ = provider.Validate(context.Background(), "0000000000000000000000000000000000000000000000000000000000000000") + valid, _ = provider.Validate(context.Background(), "0000000000000000000000000000000000000000000000000000000000000000") //nolint:errcheck // Example fmt.Printf("Wrong ID valid: %v\n", valid) // Output: @@ -134,7 +134,7 @@ func ExampleProvider_ID_allComponents() { func isAllHex(s string) bool { for _, c := range s { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } diff --git a/example_windows_test.go b/example_windows_test.go new file mode 100644 index 0000000..a9b17a9 --- /dev/null +++ b/example_windows_test.go @@ -0,0 +1,88 @@ +//go:build windows + +package machineid_test + +import ( + "context" + "fmt" + + "github.com/slashdevops/machineid" +) + +// ExampleProvider_Diagnostics demonstrates inspecting which hardware components +// were successfully collected on Windows. +func ExampleProvider_Diagnostics() { + provider := machineid.New(). + WithCPU(). + WithSystemUUID() + + //nolint:errcheck // Example: error handling omitted for brevity + 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 +} + +// Example_integrity demonstrates that the format maintains integrity without collisions. +func Example_integrity() { + 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()) //nolint:errcheck // Example + id2, _ := p2.ID(context.Background()) //nolint:errcheck // Example + id3, _ := p3.ID(context.Background()) //nolint:errcheck // Example + id4, _ := p4.ID(context.Background()) //nolint:errcheck // Example + + // Same configuration always produces same ID + id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background()) //nolint:errcheck // Example + 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_concurrentCollection demonstrates that Windows collects hardware +// identifiers concurrently for better performance. +func Example_concurrentCollection() { + // On Windows, all hardware queries (wmic/PowerShell) run in parallel, + // reducing total latency from the sum of all commands to the max of + // any single command. + provider := machineid.New(). + WithCPU(). + WithMotherboard(). + WithSystemUUID(). + WithMAC(). + WithDisk() + + 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 +} diff --git a/installer/macos/machineid/distribution.xml b/installer/macos/machineid/distribution.xml new file mode 100644 index 0000000..e126af7 --- /dev/null +++ b/installer/macos/machineid/distribution.xml @@ -0,0 +1,16 @@ + + + machineid — Machine Identifier + + + + + + + + + + + + machineid-component.pkg + diff --git a/installer/macos/machineid/resources/license.html b/installer/macos/machineid/resources/license.html new file mode 100644 index 0000000..160f6ba --- /dev/null +++ b/installer/macos/machineid/resources/license.html @@ -0,0 +1,45 @@ + + + +

Apache License
Version 2.0, January 2004

+

http://www.apache.org/licenses/

+ +

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

+ +

1. Definitions.

+

"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.

+

"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.

+

"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.

+

"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.

+

"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.

+

"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.

+

"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work.

+

"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship.

+

"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to the Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner.

+

"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.

+ +

2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.

+ +

3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted.

+ +

4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file.

+ +

5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions.

+ +

6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work.

+ +

7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.

+ +

8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work.

+ +

9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License.

+ +

Copyright 2026 slashdevops

+

Licensed under the Apache License, Version 2.0. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0

+ + diff --git a/installer/macos/machineid/resources/readme.html b/installer/macos/machineid/resources/readme.html new file mode 100644 index 0000000..28356d2 --- /dev/null +++ b/installer/macos/machineid/resources/readme.html @@ -0,0 +1,64 @@ + + + +

Installation Details

+ +

Install location

+ + + +
FilePath
Binary/usr/local/bin/machineid
+ +

Getting started

+
# Generate a machine ID using default hardware signals
+machineid
+
+# Use all available hardware signals
+machineid -all
+
+# VM-friendly mode (CPU + System UUID only)
+machineid -vm
+
+# Generate with a custom salt
+machineid -all -salt "my-app-name"
+
+# JSON output with diagnostics
+machineid -all -json -diagnostics
+
+# Validate a previously generated ID
+machineid -all -validate "your-previously-generated-id"
+ +

Features

+ + +

How to uninstall

+
# Remove the binary
+sudo rm /usr/local/bin/machineid
+
+# Remove the installer receipt
+sudo pkgutil --forget com.slashdevops.machineid
+ +

Links

+ + + diff --git a/installer/macos/machineid/resources/welcome.html b/installer/macos/machineid/resources/welcome.html new file mode 100644 index 0000000..e7c175b --- /dev/null +++ b/installer/macos/machineid/resources/welcome.html @@ -0,0 +1,21 @@ + + + +

machineid — Machine Identifier

+

This installer will install the machineid command-line tool on your Mac.

+

machineid generates unique, deterministic machine identifiers from hardware characteristics. IDs are stable across reboots and ideal for software licensing, device fingerprinting, and telemetry.

+

What gets installed

+ +

Requirements

+ + + diff --git a/linux_test.go b/linux_test.go new file mode 100644 index 0000000..2180cf5 --- /dev/null +++ b/linux_test.go @@ -0,0 +1,417 @@ +//go:build linux + +package machineid + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" +) + +// --- parseCPUInfo tests --- + +func TestParseCPUInfoFull(t *testing.T) { + content := `processor : 0 +vendor_id : GenuineIntel +cpu family : 6 +model : 158 +model name : Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz +stepping : 10 +cpu MHz : 2600.000 +flags : fpu vme de pse tsc msr pae mce +` + result := parseCPUInfo(content) + expected := "0:GenuineIntel:Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz:fpu vme de pse tsc msr pae mce" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestParseCPUInfoEmpty(t *testing.T) { + result := parseCPUInfo("") + expected := ":::" + if result != expected { + t.Errorf("Expected %q for empty input, got %q", expected, result) + } +} + +func TestParseCPUInfoPartial(t *testing.T) { + content := `processor : 3 +vendor_id : AuthenticAMD +model name : AMD Ryzen 9 5950X +` + result := parseCPUInfo(content) + expected := "3:AuthenticAMD:AMD Ryzen 9 5950X:" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestParseCPUInfoNoColon(t *testing.T) { + content := "some line without colon\nanother line\n" + result := parseCPUInfo(content) + expected := ":::" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestParseCPUInfoMultipleProcessors(t *testing.T) { + // parseCPUInfo keeps overwriting, so the last processor block wins + content := `processor : 0 +vendor_id : GenuineIntel +model name : Intel Core i7 +flags : fpu vme + +processor : 1 +vendor_id : GenuineIntel +model name : Intel Core i7 +flags : fpu vme avx +` + result := parseCPUInfo(content) + // Last processor's values should win + expected := "1:GenuineIntel:Intel Core i7:fpu vme avx" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +// --- isValidUUID tests --- + +func TestIsValidUUID(t *testing.T) { + tests := []struct { + name string + uuid string + valid bool + }{ + {"valid UUID", "4C4C4544-0058-5210-8048-B4C04F595031", true}, + {"empty", "", false}, + {"null UUID", "00000000-0000-0000-0000-000000000000", false}, + {"simple string", "abc123", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isValidUUID(tt.uuid); got != tt.valid { + t.Errorf("isValidUUID(%q) = %v, want %v", tt.uuid, got, tt.valid) + } + }) + } +} + +// --- isValidSerial tests --- + +func TestIsValidSerial(t *testing.T) { + tests := []struct { + name string + serial string + valid bool + }{ + {"valid serial", "ABC12345", true}, + {"empty", "", false}, + {"OEM placeholder", "To be filled by O.E.M.", false}, + {"simple string", "SERIAL123", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isValidSerial(tt.serial); got != tt.valid { + t.Errorf("isValidSerial(%q) = %v, want %v", tt.serial, got, tt.valid) + } + }) + } +} + +// --- isNonEmpty tests --- + +func TestIsNonEmpty(t *testing.T) { + if isNonEmpty("") { + t.Error("Expected false for empty string") + } + if !isNonEmpty("hello") { + t.Error("Expected true for non-empty string") + } +} + +// --- linuxDiskSerialsLSBLK tests --- + +func TestLinuxDiskSerialsLSBLKSuccess(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", "WD-12345\nSAMSUNG-67890\n") + + serials, err := linuxDiskSerialsLSBLK(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(serials) != 2 { + t.Fatalf("Expected 2 serials, got %d", len(serials)) + } + if serials[0] != "WD-12345" || serials[1] != "SAMSUNG-67890" { + t.Errorf("Unexpected serials: %v", serials) + } +} + +func TestLinuxDiskSerialsLSBLKEmpty(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", "\n\n") + + serials, err := linuxDiskSerialsLSBLK(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(serials) != 0 { + t.Errorf("Expected 0 serials for empty output, got %d", len(serials)) + } +} + +func TestLinuxDiskSerialsLSBLKError(t *testing.T) { + mock := newMockExecutor() + mock.setError("lsblk", fmt.Errorf("lsblk not found")) + + _, err := linuxDiskSerialsLSBLK(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when lsblk fails") + } +} + +func TestLinuxDiskSerialsLSBLKSkipsEmpty(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", "WD-12345\n\n\nSAMSUNG-67890\n") + + serials, err := linuxDiskSerialsLSBLK(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(serials) != 2 { + t.Errorf("Expected 2 serials (empty lines skipped), got %d", len(serials)) + } +} + +// --- linuxDiskSerials deduplication tests --- + +func TestLinuxDiskSerialsDeduplicated(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", "SERIAL-A\nSERIAL-B\n") + + serials, err := linuxDiskSerials(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // lsblk should succeed; /sys/block will likely fail in test environment + t.Logf("Found %d disk serials from lsblk", len(serials)) +} + +func TestLinuxDiskSerialsWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("lsblk", "SERIAL-LOG\n") + + _, err := linuxDiskSerials(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !bytes.Contains(buf.Bytes(), []byte("collected disk serials via lsblk")) { + t.Error("Expected 'collected disk serials via lsblk' in log") + } +} + +// --- Provider integration tests with mock executor (Linux) --- + +func TestProviderWithMockExecutor(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", "SERIAL-A\n") + + p := New().WithExecutor(mock).WithDisk() + + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + if len(id) != 64 { + t.Errorf("Expected 64-char ID, got %d", len(id)) + } +} + +func TestProviderErrorHandlingLinux(t *testing.T) { + mock := newMockExecutor() + mock.setError("lsblk", fmt.Errorf("lsblk not found")) + + // Disk-only: lsblk fails, /sys/block also likely fails in test + p := New().WithExecutor(mock).WithDisk() + + _, err := p.ID(context.Background()) + // This might succeed or fail depending on /sys/block availability + t.Logf("ID() result: err=%v", err) +} + +func TestProviderDiagnosticsLinux(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", "SERIAL\n") + + p := New().WithExecutor(mock).WithDisk() + + if p.Diagnostics() != nil { + t.Error("Diagnostics should be nil before ID()") + } + + if _, err := p.ID(context.Background()); err != nil { + t.Logf("ID() error (may be expected): %v", err) + } + + diag := p.Diagnostics() + if diag == nil { + t.Fatal("Diagnostics should not be nil after ID()") + } + t.Logf("Collected: %v, Errors: %v", diag.Collected, diag.Errors) +} + +func TestProviderWithLoggerLinux(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("lsblk", "SERIAL\n") + + p := New().WithExecutor(mock).WithLogger(logger).WithDisk() + + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + if !bytes.Contains(buf.Bytes(), []byte("generating machine ID")) { + t.Error("Expected 'generating machine ID' in log") + } +} + +func TestProviderValidateLinux(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", "SERIAL\n") + + p := New().WithExecutor(mock).WithDisk() + + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + valid, err := p.Validate(context.Background(), id) + if err != nil { + t.Fatalf("Validate error: %v", err) + } + if !valid { + t.Error("Expected validation to succeed") + } + + valid, err = p.Validate(context.Background(), "wrong-id") + if err != nil { + t.Fatalf("Validate error: %v", err) + } + if valid { + t.Error("Expected validation to fail for wrong ID") + } +} + +func TestCollectIdentifiersLinuxAllFail(t *testing.T) { + mock := newMockExecutor() + mock.setError("lsblk", fmt.Errorf("not found")) + + p := New().WithExecutor(mock).WithCPU().WithSystemUUID() + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + identifiers, err := collectIdentifiers(context.Background(), p, diag) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // CPU and UUID read from files, not commands — they may or may not work + // depending on the test environment + t.Logf("Identifiers: %d, Collected: %v, Errors: %v", len(identifiers), diag.Collected, diag.Errors) +} + +func TestCollectIdentifiersLinuxNoComponents(t *testing.T) { + p := New() + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + identifiers, err := collectIdentifiers(context.Background(), p, diag) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(identifiers) != 0 { + t.Errorf("Expected 0 identifiers with no components, got %d", len(identifiers)) + } +} + +func TestValidateErrorLinux(t *testing.T) { + mock := newMockExecutor() + mock.setError("lsblk", fmt.Errorf("command failed")) + + // WithCPU on Linux reads files, not commands — might not fail. + // Use a component that requires the mock executor to ensure failure. + p := New().WithExecutor(mock) + // Don't enable any components — will get ErrNoIdentifiers + _, err := p.ID(context.Background()) + if err == nil { + // No components enabled means no identifiers + t.Logf("No error with no components enabled (expected)") + } +} + +func TestProviderCachedIDLinux(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", "SERIAL1\n") + + p := New().WithExecutor(mock).WithDisk() + + id1, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("First ID() error: %v", err) + } + + mock.setOutput("lsblk", "SERIAL2\n") + + id2, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("Second ID() error: %v", err) + } + + if id1 != id2 { + t.Error("Cached ID was modified on subsequent call") + } +} + +// --- readFirstValidFromLocations tests --- + +func TestReadFirstValidFromLocationsAllFail(t *testing.T) { + locations := []string{ + "/nonexistent/path/1", + "/nonexistent/path/2", + } + + _, err := readFirstValidFromLocations(locations, isNonEmpty, nil) + if err == nil { + t.Error("Expected error when all locations fail") + } + if !errors.Is(err, ErrNotFound) { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestReadFirstValidFromLocationsWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + locations := []string{"/nonexistent/path/test"} + + if _, err := readFirstValidFromLocations(locations, isNonEmpty, logger); err == nil { + t.Error("Expected error for nonexistent path") + } + if !bytes.Contains(buf.Bytes(), []byte("failed to read file")) { + t.Error("Expected 'failed to read file' in log") + } +} diff --git a/machineid.go b/machineid.go index 54cd372..20208bb 100644 --- a/machineid.go +++ b/machineid.go @@ -87,12 +87,12 @@ type Provider struct { salt string cachedID string formatMode FormatMode + macFilter MACFilter mu sync.Mutex includeCPU bool includeMotherboard bool includeSystemUUID bool includeMAC bool - macFilter MACFilter includeDisk bool } diff --git a/machineid_test.go b/machineid_test.go index c25c469..aeb242e 100644 --- a/machineid_test.go +++ b/machineid_test.go @@ -392,9 +392,18 @@ func TestFormatDifference(t *testing.T) { 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()) + id32, err := g32.ID(context.Background()) + if err != nil { + t.Fatalf("Format32 ID() error: %v", err) + } + id64, err := g64.ID(context.Background()) + if err != nil { + t.Fatalf("Format64 ID() error: %v", err) + } + id128, err := g128.ID(context.Background()) + if err != nil { + t.Fatalf("Format128 ID() error: %v", err) + } // Format32 should be the first 32 chars of Format64 if id32 != id64[:32] { diff --git a/network_test.go b/network_test.go index b657d66..5e57d78 100644 --- a/network_test.go +++ b/network_test.go @@ -34,9 +34,18 @@ func TestCollectMACAddressesAllFilter(t *testing.T) { // TestCollectMACAddressesVirtualFilter tests that virtual filter excludes physical interfaces. func TestCollectMACAddressesVirtualFilter(t *testing.T) { - physical, _ := collectMACAddresses(MACFilterPhysical, nil) - virtual, _ := collectMACAddresses(MACFilterVirtual, nil) - all, _ := collectMACAddresses(MACFilterAll, nil) + physical, err := collectMACAddresses(MACFilterPhysical, nil) + if err != nil { + t.Logf("physical filter error: %v", err) + } + virtual, err := collectMACAddresses(MACFilterVirtual, nil) + if err != nil { + t.Logf("virtual filter error: %v", err) + } + all, err := collectMACAddresses(MACFilterAll, nil) + if err != nil { + t.Logf("all filter error: %v", err) + } // Virtual + physical should equal all (no overlap since classification is binary) if len(virtual)+len(physical) != len(all) { diff --git a/windows.go b/windows.go index 51356b6..d8571d0 100644 --- a/windows.go +++ b/windows.go @@ -6,46 +6,166 @@ import ( "context" "log/slog" "strings" + "sync" ) -// collectIdentifiers gathers Windows-specific hardware identifiers based on provider config. +// componentResult holds the result from a single concurrent component collection. +type componentResult struct { + component string + prefix string + value string // for single-value components + values []string // for multi-value components (MAC, disk) + err error + multi bool // true if this is a multi-value result +} + +// collectIdentifiers gathers Windows-specific hardware identifiers concurrently. +// Windows commands (wmic, PowerShell) are slow due to process startup overhead, +// so all components are collected in parallel to minimize total latency. func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) ([]string, error) { - var identifiers []string logger := p.logger + var wg sync.WaitGroup + resultsCh := make(chan componentResult, 5) + if p.includeCPU { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return windowsCPUID(ctx, p.commandExecutor, logger) - }, "cpu:", diag, ComponentCPU, logger) + wg.Add(1) + go func() { + defer wg.Done() + value, err := windowsCPUID(ctx, p.commandExecutor, logger) + resultsCh <- componentResult{component: ComponentCPU, prefix: "cpu:", value: value, err: err} + }() } if p.includeMotherboard { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return windowsMotherboardSerial(ctx, p.commandExecutor, logger) - }, "mb:", diag, ComponentMotherboard, logger) + wg.Add(1) + go func() { + defer wg.Done() + value, err := windowsMotherboardSerial(ctx, p.commandExecutor, logger) + resultsCh <- componentResult{component: ComponentMotherboard, prefix: "mb:", value: value, err: err} + }() } if p.includeSystemUUID { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return windowsSystemUUID(ctx, p.commandExecutor, logger) - }, "uuid:", diag, ComponentSystemUUID, logger) + wg.Add(1) + go func() { + defer wg.Done() + value, err := windowsSystemUUID(ctx, p.commandExecutor, logger) + resultsCh <- componentResult{component: ComponentSystemUUID, prefix: "uuid:", value: value, err: err} + }() } if p.includeMAC { - identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { - return collectMACAddresses(p.macFilter, logger) - }, "mac:", diag, ComponentMAC, logger) + wg.Add(1) + go func() { + defer wg.Done() + values, err := collectMACAddresses(p.macFilter, logger) + resultsCh <- componentResult{component: ComponentMAC, prefix: "mac:", values: values, err: err, multi: true} + }() } if p.includeDisk { - identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { - return windowsDiskSerials(ctx, p.commandExecutor, logger) - }, "disk:", diag, ComponentDisk, logger) + wg.Add(1) + go func() { + defer wg.Done() + values, err := windowsDiskSerials(ctx, p.commandExecutor, logger) + resultsCh <- componentResult{component: ComponentDisk, prefix: "disk:", values: values, err: err, multi: true} + }() + } + + // Close channel once all goroutines complete. + go func() { + wg.Wait() + close(resultsCh) + }() + + // Collect results and build identifiers. + var identifiers []string + for r := range resultsCh { + if r.multi { + identifiers = appendMultiResult(identifiers, r, diag, logger) + } else { + identifiers = appendSingleResult(identifiers, r, diag, logger) + } } return identifiers, nil } +// appendSingleResult processes a single-value component result into identifiers. +func appendSingleResult(identifiers []string, r componentResult, diag *DiagnosticInfo, logger *slog.Logger) []string { + if r.err != nil { + compErr := &ComponentError{Component: r.component, Err: r.err} + if diag != nil { + diag.Errors[r.component] = compErr + } + if logger != nil { + logger.Warn("component failed", "component", r.component, "error", r.err) + } + return identifiers + } + + if r.value == "" { + compErr := &ComponentError{Component: r.component, Err: ErrEmptyValue} + if diag != nil { + diag.Errors[r.component] = compErr + } + if logger != nil { + logger.Warn("component returned empty value", "component", r.component) + } + return identifiers + } + + if diag != nil { + diag.Collected = append(diag.Collected, r.component) + } + if logger != nil { + logger.Info("component collected", "component", r.component) + logger.Debug("component value", "component", r.component, "value", r.value) + } + + return append(identifiers, r.prefix+r.value) +} + +// appendMultiResult processes a multi-value component result into identifiers. +func appendMultiResult(identifiers []string, r componentResult, diag *DiagnosticInfo, logger *slog.Logger) []string { + if r.err != nil { + compErr := &ComponentError{Component: r.component, Err: r.err} + if diag != nil { + diag.Errors[r.component] = compErr + } + if logger != nil { + logger.Warn("component failed", "component", r.component, "error", r.err) + } + return identifiers + } + + if len(r.values) == 0 { + compErr := &ComponentError{Component: r.component, Err: ErrNoValues} + if diag != nil { + diag.Errors[r.component] = compErr + } + if logger != nil { + logger.Warn("component returned no values", "component", r.component) + } + return identifiers + } + + if diag != nil { + diag.Collected = append(diag.Collected, r.component) + } + if logger != nil { + logger.Info("component collected", "component", r.component, "count", len(r.values)) + logger.Debug("component values", "component", r.component, "values", r.values) + } + + for _, value := range r.values { + identifiers = append(identifiers, r.prefix+value) + } + + return identifiers +} + // parseWmicValue extracts value from wmic output with given prefix. func parseWmicValue(output, prefix string) (string, error) { lines := strings.SplitSeq(output, "\n") diff --git a/windows_test.go b/windows_test.go new file mode 100644 index 0000000..0f56702 --- /dev/null +++ b/windows_test.go @@ -0,0 +1,885 @@ +//go:build windows + +package machineid + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" +) + +// --- parseWmicValue tests --- + +func TestParseWmicValueValid(t *testing.T) { + output := "ProcessorId=BFEBFBFF000906EA\r\n" + value, err := parseWmicValue(output, "ProcessorId=") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if value != "BFEBFBFF000906EA" { + t.Errorf("Expected 'BFEBFBFF000906EA', got %q", value) + } +} + +func TestParseWmicValueWithBlankLines(t *testing.T) { + output := "\r\n\r\nProcessorId=BFEBFBFF000906EA\r\n\r\n" + value, err := parseWmicValue(output, "ProcessorId=") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if value != "BFEBFBFF000906EA" { + t.Errorf("Expected 'BFEBFBFF000906EA', got %q", value) + } +} + +func TestParseWmicValueEmpty(t *testing.T) { + output := "ProcessorId=\r\n" + _, err := parseWmicValue(output, "ProcessorId=") + if err == nil { + t.Error("Expected error for empty value") + } + var parseErr *ParseError + if !errors.As(err, &parseErr) { + t.Errorf("Expected ParseError, got %T", err) + } +} + +func TestParseWmicValueOEMPlaceholder(t *testing.T) { + output := "SerialNumber=To be filled by O.E.M.\r\n" + _, err := parseWmicValue(output, "SerialNumber=") + if err == nil { + t.Error("Expected error for OEM placeholder value") + } + if !errors.Is(err, ErrNotFound) { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestParseWmicValueNotFound(t *testing.T) { + output := "SomeOtherKey=value\r\n" + _, err := parseWmicValue(output, "ProcessorId=") + if err == nil { + t.Error("Expected error when prefix not found") + } + if !errors.Is(err, ErrNotFound) { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestParseWmicValueEmptyOutput(t *testing.T) { + _, err := parseWmicValue("", "ProcessorId=") + if err == nil { + t.Error("Expected error for empty output") + } +} + +// --- parseWmicMultipleValues tests --- + +func TestParseWmicMultipleValuesValid(t *testing.T) { + output := "SerialNumber=ABC123\r\nSerialNumber=DEF456\r\n" + values := parseWmicMultipleValues(output, "SerialNumber=") + if len(values) != 2 { + t.Fatalf("Expected 2 values, got %d", len(values)) + } + if values[0] != "ABC123" || values[1] != "DEF456" { + t.Errorf("Unexpected values: %v", values) + } +} + +func TestParseWmicMultipleValuesFiltersOEM(t *testing.T) { + output := "SerialNumber=ABC123\r\nSerialNumber=To be filled by O.E.M.\r\nSerialNumber=DEF456\r\n" + values := parseWmicMultipleValues(output, "SerialNumber=") + if len(values) != 2 { + t.Fatalf("Expected 2 values (OEM filtered), got %d", len(values)) + } +} + +func TestParseWmicMultipleValuesEmpty(t *testing.T) { + output := "SerialNumber=\r\n" + values := parseWmicMultipleValues(output, "SerialNumber=") + if len(values) != 0 { + t.Errorf("Expected 0 values for empty, got %d", len(values)) + } +} + +func TestParseWmicMultipleValuesNoMatch(t *testing.T) { + output := "OtherKey=value\r\n" + values := parseWmicMultipleValues(output, "SerialNumber=") + if len(values) != 0 { + t.Errorf("Expected 0 values, got %d", len(values)) + } +} + +// --- parsePowerShellValue tests --- + +func TestParsePowerShellValueValid(t *testing.T) { + value, err := parsePowerShellValue(" BFEBFBFF000906EA ") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if value != "BFEBFBFF000906EA" { + t.Errorf("Expected 'BFEBFBFF000906EA', got %q", value) + } +} + +func TestParsePowerShellValueEmpty(t *testing.T) { + _, err := parsePowerShellValue(" ") + if err == nil { + t.Error("Expected error for empty value") + } + var parseErr *ParseError + if !errors.As(err, &parseErr) { + t.Errorf("Expected ParseError, got %T", err) + } + if !errors.Is(err, ErrEmptyValue) { + t.Errorf("Expected ErrEmptyValue, got %v", err) + } +} + +// --- parsePowerShellMultipleValues tests --- + +func TestParsePowerShellMultipleValuesValid(t *testing.T) { + output := "ABC123\nDEF456\nGHI789\n" + values := parsePowerShellMultipleValues(output) + if len(values) != 3 { + t.Fatalf("Expected 3 values, got %d", len(values)) + } +} + +func TestParsePowerShellMultipleValuesSkipsEmpty(t *testing.T) { + output := "ABC123\n\n\nDEF456\n" + values := parsePowerShellMultipleValues(output) + if len(values) != 2 { + t.Fatalf("Expected 2 values (empty lines skipped), got %d", len(values)) + } +} + +func TestParsePowerShellMultipleValuesAllEmpty(t *testing.T) { + output := "\n\n\n" + values := parsePowerShellMultipleValues(output) + if len(values) != 0 { + t.Errorf("Expected 0 values, got %d", len(values)) + } +} + +// --- windowsCPUID tests --- + +func TestWindowsCPUIDWmicSuccess(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=BFEBFBFF000906EA\r\n") + + result, err := windowsCPUID(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "BFEBFBFF000906EA" { + t.Errorf("Expected 'BFEBFBFF000906EA', got %q", result) + } +} + +func TestWindowsCPUIDFallbackToPowerShell(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setOutput("powershell", "BFEBFBFF000906EA") + + result, err := windowsCPUID(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "BFEBFBFF000906EA" { + t.Errorf("Expected 'BFEBFBFF000906EA', got %q", result) + } +} + +func TestWindowsCPUIDAllFail(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setError("powershell", fmt.Errorf("powershell failed")) + + _, err := windowsCPUID(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when all methods fail") + } + if !errors.Is(err, ErrAllMethodsFailed) { + t.Errorf("Expected ErrAllMethodsFailed, got %v", err) + } +} + +func TestWindowsCPUIDWmicParseFailFallback(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "garbage output") // wmic succeeds but parse fails + mock.setOutput("powershell", "BFEBFBFF000906EA") // PowerShell fallback succeeds + + result, err := windowsCPUID(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "BFEBFBFF000906EA" { + t.Errorf("Expected 'BFEBFBFF000906EA', got %q", result) + } +} + +func TestWindowsCPUIDWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setOutput("powershell", "BFEBFBFF000906EA") + + _, err := windowsCPUID(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !bytes.Contains(buf.Bytes(), []byte("falling back to PowerShell for CPU ID")) { + t.Error("Expected fallback log message") + } +} + +// --- windowsMotherboardSerial tests --- + +func TestWindowsMotherboardSerialWmicSuccess(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "SerialNumber=ABC12345\r\n") + + result, err := windowsMotherboardSerial(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "ABC12345" { + t.Errorf("Expected 'ABC12345', got %q", result) + } +} + +func TestWindowsMotherboardSerialFallbackToPowerShell(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setOutput("powershell", "ABC12345") + + result, err := windowsMotherboardSerial(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "ABC12345" { + t.Errorf("Expected 'ABC12345', got %q", result) + } +} + +func TestWindowsMotherboardSerialOEMPlaceholder(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setOutput("powershell", "To be filled by O.E.M.") + + _, err := windowsMotherboardSerial(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error for OEM placeholder") + } + if !errors.Is(err, ErrOEMPlaceholder) { + t.Errorf("Expected ErrOEMPlaceholder, got %v", err) + } +} + +func TestWindowsMotherboardSerialAllFail(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setError("powershell", fmt.Errorf("powershell failed")) + + _, err := windowsMotherboardSerial(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when all methods fail") + } + if !errors.Is(err, ErrAllMethodsFailed) { + t.Errorf("Expected ErrAllMethodsFailed, got %v", err) + } +} + +func TestWindowsMotherboardSerialWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setError("powershell", fmt.Errorf("powershell failed")) + + if _, err := windowsMotherboardSerial(context.Background(), mock, logger); err == nil { + t.Error("Expected error when all methods fail") + } + if !bytes.Contains(buf.Bytes(), []byte("falling back to PowerShell for motherboard serial")) { + t.Error("Expected fallback log message") + } + if !bytes.Contains(buf.Bytes(), []byte("all motherboard serial methods failed")) { + t.Error("Expected all-methods-failed log message") + } +} + +// --- windowsSystemUUID tests --- + +func TestWindowsSystemUUIDWmicSuccess(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "UUID=4C4C4544-0058-5210-8048-B4C04F595031\r\n") + + result, err := windowsSystemUUID(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "4C4C4544-0058-5210-8048-B4C04F595031" { + t.Errorf("Expected UUID, got %q", result) + } +} + +func TestWindowsSystemUUIDFallbackToPowerShell(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setOutput("powershell", "4C4C4544-0058-5210-8048-B4C04F595031") + + result, err := windowsSystemUUID(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "4C4C4544-0058-5210-8048-B4C04F595031" { + t.Errorf("Expected UUID, got %q", result) + } +} + +func TestWindowsSystemUUIDAllFail(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setError("powershell", fmt.Errorf("powershell failed")) + + _, err := windowsSystemUUID(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when all methods fail") + } +} + +func TestWindowsSystemUUIDWmicParseFailFallback(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "garbage") // parse fails + mock.setOutput("powershell", "UUID-FROM-PS") + + result, err := windowsSystemUUID(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "UUID-FROM-PS" { + t.Errorf("Expected 'UUID-FROM-PS', got %q", result) + } +} + +func TestWindowsSystemUUIDWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("wmic", "garbage") + mock.setOutput("powershell", "UUID-LOGGED") + + _, err := windowsSystemUUID(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !bytes.Contains(buf.Bytes(), []byte("wmic UUID parsing failed")) { + t.Error("Expected 'wmic UUID parsing failed' in log") + } + if !bytes.Contains(buf.Bytes(), []byte("falling back to PowerShell for system UUID")) { + t.Error("Expected fallback log message") + } +} + +// --- windowsSystemUUIDViaPowerShell tests --- + +func TestWindowsSystemUUIDViaPowerShellSuccess(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("powershell", "UUID-PS-123") + + result, err := windowsSystemUUIDViaPowerShell(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "UUID-PS-123" { + t.Errorf("Expected 'UUID-PS-123', got %q", result) + } +} + +func TestWindowsSystemUUIDViaPowerShellError(t *testing.T) { + mock := newMockExecutor() + mock.setError("powershell", fmt.Errorf("powershell failed")) + + _, err := windowsSystemUUIDViaPowerShell(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when PowerShell fails") + } +} + +func TestWindowsSystemUUIDViaPowerShellEmpty(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("powershell", " ") + + _, err := windowsSystemUUIDViaPowerShell(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error for empty PowerShell output") + } + if !errors.Is(err, ErrEmptyValue) { + t.Errorf("Expected ErrEmptyValue, got %v", err) + } +} + +// --- windowsDiskSerials tests --- + +func TestWindowsDiskSerialsWmicSuccess(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "SerialNumber=WD-12345\r\nSerialNumber=WD-67890\r\n") + + result, err := windowsDiskSerials(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(result) != 2 { + t.Fatalf("Expected 2 serials, got %d", len(result)) + } + if result[0] != "WD-12345" || result[1] != "WD-67890" { + t.Errorf("Unexpected serials: %v", result) + } +} + +func TestWindowsDiskSerialsFallbackToPowerShell(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setOutput("powershell", "WD-12345\nWD-67890") + + result, err := windowsDiskSerials(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(result) != 2 { + t.Fatalf("Expected 2 serials, got %d", len(result)) + } +} + +func TestWindowsDiskSerialsAllFail(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setError("powershell", fmt.Errorf("powershell failed")) + + _, err := windowsDiskSerials(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error when all methods fail") + } + if !errors.Is(err, ErrAllMethodsFailed) { + t.Errorf("Expected ErrAllMethodsFailed, got %v", err) + } +} + +func TestWindowsDiskSerialsPowerShellEmpty(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setOutput("powershell", "\n\n") + + _, err := windowsDiskSerials(context.Background(), mock, nil) + if err == nil { + t.Error("Expected error for empty PowerShell output") + } + if !errors.Is(err, ErrNotFound) { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestWindowsDiskSerialsWmicEmptyFallback(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "SerialNumber=\r\n") // wmic returns empty serials + mock.setOutput("powershell", "WD-FALLBACK") + + result, err := windowsDiskSerials(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(result) != 1 || result[0] != "WD-FALLBACK" { + t.Errorf("Expected [WD-FALLBACK], got %v", result) + } +} + +func TestWindowsDiskSerialsWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("wmic", "SerialNumber=\r\n") + mock.setOutput("powershell", "WD-LOG") + + _, err := windowsDiskSerials(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !bytes.Contains(buf.Bytes(), []byte("wmic returned no disk serials")) { + t.Error("Expected 'wmic returned no disk serials' in log") + } + if !bytes.Contains(buf.Bytes(), []byte("falling back to PowerShell for disk serials")) { + t.Error("Expected fallback log message") + } +} + +// --- appendSingleResult tests --- + +func TestAppendSingleResultSuccess(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + r := componentResult{component: "cpu", prefix: "cpu:", value: "CPUID123"} + + result := appendSingleResult(nil, r, diag, nil) + if len(result) != 1 { + t.Fatalf("Expected 1 identifier, got %d", len(result)) + } + if result[0] != "cpu:CPUID123" { + t.Errorf("Expected 'cpu:CPUID123', got %q", result[0]) + } + if len(diag.Collected) != 1 || diag.Collected[0] != "cpu" { + t.Errorf("Expected cpu in collected, got %v", diag.Collected) + } +} + +func TestAppendSingleResultError(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + r := componentResult{component: "cpu", prefix: "cpu:", err: fmt.Errorf("failed")} + + result := appendSingleResult(nil, r, diag, nil) + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) + } + if _, exists := diag.Errors["cpu"]; !exists { + t.Error("Expected error recorded for cpu") + } +} + +func TestAppendSingleResultEmpty(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + r := componentResult{component: "cpu", prefix: "cpu:", value: ""} + + result := appendSingleResult(nil, r, diag, nil) + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) + } + if _, exists := diag.Errors["cpu"]; !exists { + t.Error("Expected error recorded for empty cpu") + } + var compErr *ComponentError + if !errors.As(diag.Errors["cpu"], &compErr) { + t.Error("Expected ComponentError") + } +} + +func TestAppendSingleResultWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + diag := &DiagnosticInfo{Errors: make(map[string]error)} + r := componentResult{component: "cpu", prefix: "cpu:", value: "CPUID123"} + + appendSingleResult(nil, r, diag, logger) + if !bytes.Contains(buf.Bytes(), []byte("component collected")) { + t.Error("Expected 'component collected' log") + } + if !bytes.Contains(buf.Bytes(), []byte("component value")) { + t.Error("Expected 'component value' log") + } +} + +func TestAppendSingleResultNilDiag(t *testing.T) { + r := componentResult{component: "cpu", prefix: "cpu:", value: "CPUID123"} + result := appendSingleResult(nil, r, nil, nil) + if len(result) != 1 { + t.Errorf("Expected 1 identifier with nil diag, got %d", len(result)) + } +} + +// --- appendMultiResult tests --- + +func TestAppendMultiResultSuccess(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + r := componentResult{component: "disk", prefix: "disk:", values: []string{"SN1", "SN2"}, multi: true} + + result := appendMultiResult(nil, r, diag, nil) + if len(result) != 2 { + t.Fatalf("Expected 2 identifiers, got %d", len(result)) + } + if result[0] != "disk:SN1" || result[1] != "disk:SN2" { + t.Errorf("Unexpected identifiers: %v", result) + } + if len(diag.Collected) != 1 || diag.Collected[0] != "disk" { + t.Errorf("Expected disk in collected, got %v", diag.Collected) + } +} + +func TestAppendMultiResultError(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + r := componentResult{component: "disk", prefix: "disk:", err: fmt.Errorf("failed"), multi: true} + + result := appendMultiResult(nil, r, diag, nil) + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) + } + if _, exists := diag.Errors["disk"]; !exists { + t.Error("Expected error recorded for disk") + } +} + +func TestAppendMultiResultEmpty(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + r := componentResult{component: "disk", prefix: "disk:", values: []string{}, multi: true} + + result := appendMultiResult(nil, r, diag, nil) + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) + } + if _, exists := diag.Errors["disk"]; !exists { + t.Error("Expected error recorded for empty disk") + } + var compErr *ComponentError + if !errors.As(diag.Errors["disk"], &compErr) { + t.Error("Expected ComponentError") + } + if !errors.Is(diag.Errors["disk"], ErrNoValues) { + t.Error("Expected ErrNoValues") + } +} + +func TestAppendMultiResultWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + diag := &DiagnosticInfo{Errors: make(map[string]error)} + r := componentResult{component: "disk", prefix: "disk:", values: []string{"SN1"}, multi: true} + + appendMultiResult(nil, r, diag, logger) + if !bytes.Contains(buf.Bytes(), []byte("component collected")) { + t.Error("Expected 'component collected' log") + } + if !bytes.Contains(buf.Bytes(), []byte("component values")) { + t.Error("Expected 'component values' log") + } +} + +func TestAppendMultiResultNilDiag(t *testing.T) { + r := componentResult{component: "disk", prefix: "disk:", values: []string{"SN1"}, multi: true} + result := appendMultiResult(nil, r, nil, nil) + if len(result) != 1 { + t.Errorf("Expected 1 identifier with nil diag, got %d", len(result)) + } +} + +// --- collectIdentifiers concurrent collection tests --- + +func TestCollectIdentifiersConcurrent(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=CPUID123\r\n") + mock.setOutput("powershell", "UUID-FROM-PS") + + p := New().WithExecutor(mock).WithCPU().WithSystemUUID() + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + identifiers, err := collectIdentifiers(context.Background(), p, diag) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(identifiers) == 0 { + t.Error("Expected at least one identifier") + } + if len(diag.Collected) == 0 { + t.Error("Expected at least one collected component") + } +} + +func TestCollectIdentifiersAllComponents(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=CPUID\r\nSerialNumber=MBSERIAL\r\nUUID=UUID123\r\n") + + p := New().WithExecutor(mock). + WithCPU(). + WithMotherboard(). + WithSystemUUID(). + WithMAC(). + WithDisk() + + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + identifiers, err := collectIdentifiers(context.Background(), p, diag) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // At minimum, MAC should succeed (uses net.Interfaces, not mocked) + // Other components depend on mock matching command names + t.Logf("Collected %d identifiers, %d components", len(identifiers), len(diag.Collected)) + t.Logf("Errors: %v", diag.Errors) +} + +func TestCollectIdentifiersNoComponents(t *testing.T) { + p := New() + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + identifiers, err := collectIdentifiers(context.Background(), p, diag) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(identifiers) != 0 { + t.Errorf("Expected 0 identifiers with no components, got %d", len(identifiers)) + } +} + +func TestCollectIdentifiersAllFail(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setError("powershell", fmt.Errorf("powershell failed")) + + p := New().WithExecutor(mock).WithCPU().WithSystemUUID() + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + identifiers, err := collectIdentifiers(context.Background(), p, diag) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // All components should fail, identifiers should be empty + if len(identifiers) != 0 { + t.Errorf("Expected 0 identifiers when all fail, got %d", len(identifiers)) + } + if len(diag.Errors) == 0 { + t.Error("Expected errors recorded in diagnostics") + } +} + +// --- Provider integration tests with mock executor (Windows) --- + +func TestProviderWithMockExecutor(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=BFEBFBFF000906EA\r\n") + + p := New().WithExecutor(mock).WithCPU() + + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + if len(id) != 64 { + t.Errorf("Expected 64-char ID, got %d", len(id)) + } + + // Verify caching + id2, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("Second ID() error: %v", err) + } + if id != id2 { + t.Error("Cached ID mismatch") + } +} + +func TestProviderErrorHandling(t *testing.T) { + mock := newMockExecutor() + mock.setError("wmic", fmt.Errorf("wmic not found")) + mock.setError("powershell", fmt.Errorf("powershell failed")) + + p := New().WithExecutor(mock).WithCPU() + + _, err := p.ID(context.Background()) + if err == nil { + t.Error("Expected error when all commands fail") + } + if !errors.Is(err, ErrNoIdentifiers) { + t.Errorf("Expected ErrNoIdentifiers, got %v", err) + } +} + +func TestProviderDiagnosticsWindows(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=CPUID123\r\n") + + p := New().WithExecutor(mock).WithCPU().WithSystemUUID() + + if p.Diagnostics() != nil { + t.Error("Diagnostics should be nil before ID()") + } + + if _, err := p.ID(context.Background()); err != nil { + t.Logf("ID() error (may be expected): %v", err) + } + + diag := p.Diagnostics() + if diag == nil { + t.Fatal("Diagnostics should not be nil after ID()") + } + t.Logf("Collected: %v, Errors: %v", diag.Collected, diag.Errors) +} + +func TestProviderWithLoggerWindows(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=CPUID123\r\n") + + p := New().WithExecutor(mock).WithLogger(logger).WithCPU() + + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + if !bytes.Contains(buf.Bytes(), []byte("generating machine ID")) { + t.Error("Expected 'generating machine ID' in log") + } + if !bytes.Contains(buf.Bytes(), []byte("machine ID generated")) { + t.Error("Expected 'machine ID generated' in log") + } +} + +func TestProviderCachedIDWindows(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=CPU1\r\n") + + p := New().WithExecutor(mock).WithCPU() + + id1, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("First ID() error: %v", err) + } + + // Change mock - should still return cached + mock.setOutput("wmic", "ProcessorId=CPU2\r\n") + + id2, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("Second ID() error: %v", err) + } + if id1 != id2 { + t.Error("Cached ID was modified on subsequent call") + } +} + +func TestProviderValidateWindows(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=CPUID\r\n") + + p := New().WithExecutor(mock).WithCPU() + + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + valid, err := p.Validate(context.Background(), id) + if err != nil { + t.Fatalf("Validate error: %v", err) + } + if !valid { + t.Error("Expected validation to succeed") + } + + valid, err = p.Validate(context.Background(), "wrong-id") + if err != nil { + t.Fatalf("Validate error: %v", err) + } + if valid { + t.Error("Expected validation to fail for wrong ID") + } +}