#!/bin/sh # Attribution after successful installation. No downloaded executable/runtime. # The service inserts attr_service, attr_project, attr_path (a per-install-path # hook) and, when the website has a primary origin, attr_site right after the shebang. set -u umask 077 # The person's language, read before the script pins its own locale. attr_lang=${LC_ALL:-${LC_MESSAGES:-${LANG:-}}} LC_ALL=C export LC_ALL attr_service=${attr_service:-} attr_project=${attr_project:-} attr_site=${attr_site:-} attr_path=${attr_path:-} attr_path_from='set in this hook' [ -z "${CLITRAIL_INSTALL_PATH:-}" ] || { attr_path=$CLITRAIL_INSTALL_PATH; attr_path_from='from CLITRAIL_INSTALL_PATH'; } attr_url_id= attr_event=install_completed attr_event_id= attr_scope=${CLITRAIL_INSTALLATION_SCOPE:-default} attr_state=${CLITRAIL_STATE_DIR:-${HOME:-}/.local/state/clitrail} # The default folder's name before the rename; an explicit folder never falls back. attr_legacy= [ -n "${CLITRAIL_STATE_DIR:-}" ] || attr_legacy=${HOME:-}/.local/state/opfs-attribution-shell attr_dry=0 attr_doctor=0 attr_handoff=${CLITRAIL_HANDOFF:-} attr_handoff_mode=0 [ "${CLITRAIL_HANDOFF+x}" != x ] || attr_handoff_mode=1 # Opt-outs come from the environment alone, so an opted-out run ends before it # runs a command or reads a file: DO_NOT_TRACK (consoledonottrack.com), # CLITRAIL_DISABLE, and CI runners, where no person chose to install. attr_off= case "${DO_NOT_TRACK:-}" in ''|0|false) ;; *) attr_off=DO_NOT_TRACK;; esac case "${CLITRAIL_DISABLE:-}" in ''|0|false) ;; *) attr_off=${attr_off:-CLITRAIL_DISABLE};; esac attr_ci= case "${CI-false}" in false|0) ;; *) attr_ci=CI;; esac for attr_var in "${GITHUB_ACTIONS+GITHUB_ACTIONS}" "${GITLAB_CI+GITLAB_CI}" "${BUILDKITE+BUILDKITE}" \ "${CIRCLECI+CIRCLECI}" "${TF_BUILD+TF_BUILD}" "${JENKINS_URL+JENKINS_URL}"; do attr_ci=${attr_ci:-$attr_var} done attr_valued() { case "$1" in --endpoint|--project|--url-id|--event|--event-id|--installation-scope|--install-path|--state-dir|--root|--handoff) return 0;; esac return 1 } # Only --doctor (local diagnostics, never a request) looks past an opt-out. attr_skip=0 for attr_arg do if [ "$attr_skip" -eq 1 ]; then attr_skip=0 elif [ "$attr_arg" = --doctor ]; then attr_doctor=1 elif attr_valued "$attr_arg"; then attr_skip=1; fi done if [ "$attr_doctor" -eq 0 ]; then [ -z "$attr_off" ] || exit 0 [ -z "$attr_ci" ] || [ "${CLITRAIL_ALLOW_CI:-}" = 1 ] || exit 0 fi # Installs stay silent on every failure; --doctor says why it stopped. attr_quit() { [ "$attr_doctor" -eq 0 ] || printf 'clitrail doctor: %s\n' "$1"; exit 0; } for attr_tool in awk find mktemp date od tr mkdir cat rm rmdir uname; do command -v "$attr_tool" >/dev/null 2>&1 || attr_quit "the $attr_tool utility is missing, so installs report nothing" done attr_work=$(mktemp -d "${TMPDIR:-/tmp}/clitrail.XXXXXXXX") || attr_quit 'no private temporary folder could be created' attr_cleanup() { rm -f "$attr_work/roots" "$attr_work/labels" "$attr_work/families" "$attr_work/present" "$attr_work/tally" \ "$attr_work/scan.awk" "$attr_work/count" "$attr_work/stop" "$attr_work/raw" "$attr_work/receipts" \ "$attr_work/answer" "$attr_work/request" rmdir "$attr_work" 2>/dev/null || true } trap attr_cleanup 0 trap 'exit 0' HUP INT TERM # roots holds one folder per line; labels holds its "family/profile" on the # same line number, the only names --doctor may print. present holds the # browser family of each installed browser whose folder exists, as a website # visit names it (Chrome Beta is chrome, Zen is firefox): the "browsers" field. : > "$attr_work/roots" : > "$attr_work/labels" : > "$attr_work/families" : > "$attr_work/present" attr_explicit=0 attr_identifier() { case "$1" in ''|*[!A-Za-z0-9_-]*) return 1;; esac [ "${#1}" -le 64 ] } # Reject symlinks in roots/state paths. Never resolve a rejected path elsewhere. attr_safe_path() { case "$1" in /*) ;; *) return 1;; esac case "$1/" in *'/../'*|*'/./'*) return 1;; esac attr_check=$1 while [ "$attr_check" != / ] && [ -n "$attr_check" ]; do [ ! -L "$attr_check" ] || return 1 attr_check=${attr_check%/*} done } attr_add_root() { case "$1" in *' '*) return;; esac if [ -d "$1" ] && attr_safe_path "$1"; then printf '%s\n' "$1" >> "$attr_work/roots" printf '%s\n' "$2" >> "$attr_work/labels" fi } # Existence checks name a browser (and its family, when given as $3); the # Chromium layout keeps one "File System" folder per profile. attr_installed() { [ -d "$1" ] || return 1 printf '%s\n' "$2" >> "$attr_work/families" [ -z "${3:-}" ] || printf '%s\n' "$3" >> "$attr_work/present" } attr_chromium() { attr_safe_path "$1" || return attr_installed "$@" || return 0 for attr_profile in "$1/Default" "$1"/Profile\ *; do attr_add_root "$attr_profile/File System" "$2/${attr_profile##*/}" done } # The Firefox layout keeps profiles in one folder and one "fs" folder per # site, so its label names the profile, never the site. attr_gecko() { attr_safe_path "$1" || return attr_installed "$@" || return 0 for attr_origin in "$1"/*/storage/default/*; do attr_rel=${attr_origin#"$1"/} attr_add_root "$attr_origin/fs" "$2/${attr_rel%%/*}" done } while [ "$#" -gt 0 ]; do case "$1" in --dry-run) attr_dry=1; shift; continue;; --doctor) shift; continue;; esac attr_valued "$1" && [ "$#" -ge 2 ] || attr_quit 'unsupported option or missing option value' case "$1" in --endpoint) attr_service=$2;; --project) attr_project=$2;; --url-id) attr_url_id=$2;; --event) attr_event=$2;; --event-id) attr_event_id=$2;; --installation-scope) attr_scope=$2;; --install-path) attr_path=$2; attr_path_from='from --install-path';; --state-dir) attr_state=$2; attr_legacy=;; --handoff) attr_handoff=$2; attr_handoff_mode=1;; --root) attr_explicit=1 if [ -d "$2/Default" ]; then attr_chromium "$2" 'Custom root' elif [ -d "$2/File System" ]; then attr_add_root "$2/File System" 'Custom root/' else attr_add_root "$2" 'Custom root/'; fi;; esac shift 2 done if [ "$attr_handoff_mode" -eq 1 ]; then case "$attr_handoff" in *[!A-Za-z0-9_-]*) attr_quit 'the handoff token is malformed';; esac [ "${#attr_handoff}" -eq 43 ] || attr_quit 'the handoff token is malformed' # Explicit handoff never reads browser storage, even with --root supplied. attr_explicit=1 : > "$attr_work/roots" : > "$attr_work/labels" : > "$attr_work/families" : > "$attr_work/present" fi attr_project_ok=1 attr_identifier "$attr_project" || { [ "$attr_doctor" -eq 1 ] || exit 0; attr_project_ok=0; } attr_identifier "$attr_scope" || attr_quit 'the installation scope is invalid' [ -z "$attr_url_id" ] || attr_identifier "$attr_url_id" || attr_quit 'the --url-id value is invalid' [ -z "$attr_path" ] || attr_identifier "$attr_path" || attr_quit 'the install path ID is invalid' case "$attr_event" in install_started|install_completed|first_run) ;; *) attr_quit 'the --event value is unsupported';; esac attr_transport= if command -v curl >/dev/null 2>&1; then attr_transport=curl elif command -v wget >/dev/null 2>&1; then attr_transport=wget; fi [ -n "$attr_transport" ] || [ "$attr_dry$attr_doctor" != 00 ] || exit 0 attr_service_ok=0 case "$attr_service" in https://?*|http://127.0.0.1:*|http://localhost:*|http://\[::1\]:*) # The notice prints this URL, so it never carries control or shell characters. case "$attr_service" in *[!]A-Za-z0-9._~:/%@[-]*) ;; *) attr_service_ok=1;; esac;; esac [ "$attr_service_ok" -eq 1 ] || [ "$attr_dry$attr_doctor" != 00 ] || exit 0 # The notice names the site only when the service sent a plain origin. case "$attr_site" in http://?*|https://?*) case "${attr_site#*://}" in *[!]A-Za-z0-9.:[-]*) attr_site=;; esac;; *) attr_site=;; esac # Only the two supported families are named; any other output is omitted, # never copied into the request. case "$(uname -s 2>/dev/null)" in Darwin) attr_platform=darwin;; Linux) attr_platform=linux;; *) attr_platform=;; esac if [ "$attr_explicit$attr_doctor" = 00 ]; then [ -n "${HOME:-}" ] && [ -n "$attr_platform" ] || exit 0 fi # A person at a terminal learns what runs and how to stop it before any # browser folder is read. Pipes, logs, --dry-run and --doctor stay quiet. if [ "$attr_dry$attr_doctor" = 00 ] && [ "${CLITRAIL_QUIET:-}" != 1 ] && [ -t 2 ]; then printf 'clitrail: linking this install to your recent visit to %s (DO_NOT_TRACK=1 turns this off) — %s/installer\n' \ "${attr_site:-$attr_project}" "${attr_service%/}" >&2 fi # Each entry is "kind:label:folder", every folder from the browser's or # packager's own sources: Chromium's user_data_dir.md; Mozilla's profile code # (~/.mozilla, and the XDG folder of Firefox 147+), its Chromium-snap import # path and its Flatpak build (--persist=.mozilla); Canonical's firefox-snap # (HOME=$SNAP_USER_COMMON, MOZ_LEGACY_HOME=1); Flatpak's XDG_CONFIG_HOME and # Flathub's Chromium launcher; Zen's docs (~/.zen, and # ~/.var/app/app.zen_browser.zen/.zen in its Flatpak). The kind is the family # a visit reports (Zen, a Firefox build, counts as firefox), or gecko for Firefox's # layout. Protected Safari containers are never touched. Arc, Opera and Zen on # macOS publish no profile folder, so they are not searched. attr_discover() { for attr_base do attr_rest=${attr_base#*:} case "$attr_base" in gecko:*) attr_gecko "${attr_rest#*:}" "${attr_rest%%:*}" firefox;; *) attr_chromium "${attr_rest#*:}" "${attr_rest%%:*}" "${attr_base%%:*}";; esac done } attr_discovered=0 if [ "$attr_explicit" -eq 0 ] && [ -n "${HOME:-}" ]; then case "$attr_platform" in ?*) attr_discovered=1;; esac case "$attr_platform" in darwin) attr_app="$HOME/Library/Application Support" attr_discover "chrome:Google Chrome:$attr_app/Google/Chrome" "chrome:Google Chrome Beta:$attr_app/Google/Chrome Beta" \ "chrome:Google Chrome Dev:$attr_app/Google/Chrome Dev" "chrome:Google Chrome Canary:$attr_app/Google/Chrome Canary" \ "chromium:Chromium:$attr_app/Chromium" "edge:Microsoft Edge:$attr_app/Microsoft Edge" \ "brave:Brave:$attr_app/BraveSoftware/Brave-Browser" "vivaldi:Vivaldi:$attr_app/Vivaldi" \ "gecko:Firefox:$attr_app/Firefox/Profiles";; linux) # A relative XDG_CONFIG_HOME is invalid and ignored (XDG spec; Firefox does the same). case "${XDG_CONFIG_HOME:-}" in /*) attr_config=$XDG_CONFIG_HOME;; *) attr_config=$HOME/.config;; esac attr_flat="$HOME/.var/app" attr_discover "chrome:Google Chrome:$attr_config/google-chrome" "chrome:Google Chrome Beta:$attr_config/google-chrome-beta" \ "chrome:Google Chrome Dev:$attr_config/google-chrome-unstable" "chromium:Chromium:$attr_config/chromium" \ "chromium:Chromium (Snap):$HOME/snap/chromium/common/chromium" \ "chromium:Chromium (Flatpak):$attr_flat/org.chromium.Chromium/config/chromium" \ "edge:Microsoft Edge:$attr_config/microsoft-edge" "brave:Brave:$attr_config/BraveSoftware/Brave-Browser" \ "vivaldi:Vivaldi:$attr_config/vivaldi" "gecko:Firefox:$HOME/.mozilla/firefox" \ "gecko:Firefox:$attr_config/mozilla/firefox" "gecko:Firefox (Snap):$HOME/snap/firefox/common/.mozilla/firefox" \ "gecko:Firefox (Flatpak):$attr_flat/org.mozilla.firefox/.mozilla/firefox" \ "gecko:Firefox (Flatpak):$attr_flat/org.mozilla.firefox/config/mozilla/firefox" \ "gecko:Zen:$HOME/.zen" "gecko:Zen (Flatpak):$attr_flat/app.zen_browser.zen/.zen";; esac fi attr_now=$(date +%s) || exit 0 attr_deadline=$((attr_now + 4)) printf '0\n' > "$attr_work/count" : > "$attr_work/raw" : > "$attr_work/tally" # This parses our own small, flat JSON schema, never general browser databases. # Unknown fields, escapes, nested values, duplicate keys and partial files fail. cat > "$attr_work/scan.awk" <<'ATTR_AWK' function trim(s) { sub(/^[ \t\r\n]+/, "", s); sub(/[ \t\r\n]+$/, "", s); return s } function finish( s,n,i,p,key,value,k,part,field,quoted) { if (bad || length(blob)<80 || length(blob)>4096) return s=trim(blob) if (substr(s,1,1)!="{" || substr(s,length(s),1)!="}") return s=substr(s,2,length(s)-2) n=split(s,part,/[,]/) if (n!=8) return for (i=1;i<=n;i++) { p=index(part[i],":"); if (!p) return key=trim(substr(part[i],1,p-1)); value=trim(substr(part[i],p+1)) if (key !~ /^"[a-z_]+"$/) return key=substr(key,2,length(key)-2) if (key in field) return if (value ~ /^"[^"\\[:cntrl:]]*"$/) { quoted[key]=1; value=substr(value,2,length(value)-2) } else if (value !~ /^[0-9]+$/) return field[key]=value } if (field["format"]!="opfs-install-attribution" || !quoted["format"] || field["version"]!="1" || quoted["version"] || field["project"]!=project || !quoted["project"] || field["url_id"]!~/^[A-Za-z0-9_-]+$/ || length(field["url_id"])>64 || !quoted["url_id"] || (url_id!="" && field["url_id"]!=url_id) || field["origin"]!~/^https?:\/\// || !quoted["origin"] || field["receipt"]!~/^[A-Za-z0-9_-]+$/ || length(field["receipt"])!=43 || !quoted["receipt"] || quoted["created_at"] || quoted["expires_at"] || field["created_at"]+0<=0 || field["created_at"]+0>now*1000+60000 || field["expires_at"]+0<=now*1000 || field["expires_at"]+00) { stopped=1; exit } close(stopfile) } FNR==1 { if (NR>1) finish() blob=""; bad=0 if (checked%64==0) { "date +%s" | getline clock; close("date +%s") } if (checked>=20000 || clock>=deadline) { stopped=1; print "stop" > stopfile; exit } checked++ } { if (!bad) { blob=blob (FNR>1 ? "\n" : "") $0 if (length(blob)>4096) { bad=1; blob="" } } } END { if (!stopped) finish(); print checked+0 > counter } ATTR_AWK attr_roots=0 attr_seen=0 while IFS= read -r attr_root && IFS= read -r attr_label <&3; do [ ! -f "$attr_work/stop" ] || break attr_roots=$((attr_roots + 1)) # Without a project nothing can match, so --doctor only lists the folders. if [ "$attr_project_ok" -eq 0 ]; then printf '%s %s\n' - "$attr_label" >> "$attr_work/tally"; continue; fi # find does not follow symlinks by default. -exec passes names as arguments, # safely including spaces/newlines, without interpolating them into shell code. find "$attr_root" -type f -size +79c -size -4097c \ -exec awk -v "project=$attr_project" -v "url_id=$attr_url_id" -v "now=$attr_now" \ -v "deadline=$attr_deadline" -v "counter=$attr_work/count" -v "stopfile=$attr_work/stop" \ -f "$attr_work/scan.awk" '{}' + >> "$attr_work/raw" 2>/dev/null || true if [ "$attr_doctor" -eq 1 ]; then attr_found=$(awk 'END { print NR }' "$attr_work/raw") printf '%s %s\n' "$((attr_found - attr_seen))" "$attr_label" >> "$attr_work/tally" attr_seen=$attr_found fi done < "$attr_work/roots" 3< "$attr_work/labels" awk 'length($0)==43 && /^[A-Za-z0-9_-]+$/ && !seen[$0]++ && count++<64 { print }' \ "$attr_work/raw" > "$attr_work/receipts" # What the machine says about itself, for the dashboard's distributions and # for reconstruction: never a path, a raw app ID or anything a browser stored. # A helper gets one second and is then killed; a missing tool, a failure or a # timeout leaves the field out and never stops the install. The subshell's # stderr is closed so shells that announce killed jobs (bash, dash) stay quiet, # and the watchdog holds no output open once the helper has answered. attr_ask() { command -v "$1" >/dev/null 2>&1 && command -v sleep >/dev/null 2>&1 || return 9 ( "$@" >"$attr_work/answer" & attr_pid=$! (sleep 1; kill -9 "$attr_pid") >/dev/null & attr_dog=$! wait "$attr_pid" attr_rc=$? kill "$attr_dog" exit "$attr_rc" ) /dev/null } # Browser app IDs (macOS bundle IDs, which LaunchServices keeps in lower # case, and Linux desktop-file IDs) reduced to the family a visit reports. attr_family() { case "$1" in com.apple.safari*) attr_default=safari;; com.google.chrome*|google-chrome*) attr_default=chrome;; org.chromium.chromium*|chromium*) attr_default=chromium;; org.mozilla.firefox*|org.mozilla.nightly*|firefox*|app.zen?browser.zen*) attr_default=firefox;; com.microsoft.edge*|microsoft-edge*) attr_default=edge;; com.brave.browser*|brave-browser*) attr_default=brave;; com.operasoftware.opera*|opera*) attr_default=opera;; com.vivaldi.vivaldi*|vivaldi*) attr_default=vivaldi;; company.thebrowser.browser) attr_default=arc;; com.duckduckgo.macos.browser) attr_default=duckduckgo;; ?*) attr_default=other;; esac } attr_arch= attr_default= attr_default_from= attr_tz= if [ "$attr_dry" -eq 0 ]; then case "$(uname -m 2>/dev/null)" in arm64|aarch64) attr_arch=arm64;; x86_64|amd64) attr_arch=x86_64 # An x86_64 shell on Apple silicon runs under Rosetta 2; Apple documents # sysctl.proc_translated for telling the two apart. [ "$attr_platform" != darwin ] || [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" != 1 ] || attr_arch=arm64;; esac case "$attr_platform" in darwin) # The https handler LaunchServices keeps in the user's preferences; with # none set, macOS opens Safari. No browser data is read, nothing prompts. attr_ask defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers # 1: the preferences file or its LSHandlers list does not exist yet. case "$?" in 0|1) attr_id=$(awk ' /\{/ { depth++; if (depth == 1) { role = ""; scheme = "" } } depth == 1 && /^[ \t]*LSHandlerRoleAll = / { v = $0; sub(/^[^=]*= *"?/, "", v); sub(/"?;[ \t]*$/, "", v); role = v } depth == 1 && /^[ \t]*LSHandlerURLScheme = / { v = $0; sub(/^[^=]*= *"?/, "", v); sub(/"?;[ \t]*$/, "", v); scheme = v } /\}/ { if (depth == 1 && scheme == "https" && role != "" && role != "-") { print role; exit } depth-- } ' "$attr_work/answer" | tr 'A-Z' 'a-z') attr_family "${attr_id:-com.apple.safari}" attr_default_from='the https handler in LaunchServices' [ -n "$attr_id" ] || attr_default_from='LaunchServices has no https handler, which means Safari';; esac;; linux) # The desktop file xdg-utils names, only when that file is installed. # A helper that had to be killed is not followed by another. attr_id= attr_ask xdg-settings get default-web-browser attr_rc=$? [ "$attr_rc" -ne 0 ] || { IFS= read -r attr_id < "$attr_work/answer"; } 2>/dev/null || true attr_default_from=xdg-settings if [ -z "$attr_id" ] && [ "$attr_rc" -lt 128 ] && attr_ask xdg-mime query default x-scheme-handler/https; then { IFS= read -r attr_id < "$attr_work/answer"; } 2>/dev/null || true attr_default_from=xdg-mime fi attr_family "$(printf '%s' "$attr_id" | tr 'A-Z' 'a-z')";; esac # An IANA zone name: TZ when set, else the zoneinfo file /etc/localtime # links to, else Debian's older /etc/timezone. POSIX rules (EST5EDT) are left out. attr_tz=${TZ:-} attr_tz=${attr_tz#:} if [ -z "$attr_tz" ] && command -v readlink >/dev/null 2>&1; then attr_tz=$(readlink /etc/localtime 2>/dev/null) || attr_tz=; fi if [ -z "$attr_tz" ] && [ -f /etc/timezone ]; then { IFS= read -r attr_tz < /etc/timezone; } 2>/dev/null || true; fi case "$attr_tz" in *zoneinfo/*) attr_tz=${attr_tz##*zoneinfo/};; esac case "$attr_tz" in posix/*|right/*) attr_tz=${attr_tz#*/};; esac case "$attr_tz" in [A-Za-z]*/?*|UTC|GMT) case "$attr_tz" in *[!A-Za-z0-9_+/-]*|*//*|*/) attr_tz=;; esac;; *) attr_tz=;; esac [ "${#attr_tz}" -le 64 ] || attr_tz= # A BCP 47 language tag from the locale (en_US.UTF-8 -> en-US); a shell # without one on macOS falls back to the user's macOS region setting. attr_lang=${attr_lang%%.*} attr_lang=${attr_lang%%@*} case "$attr_lang" in ''|C|POSIX) attr_lang= if [ "$attr_platform" = darwin ] && attr_ask defaults read -g AppleLocale; then { IFS= read -r attr_lang < "$attr_work/answer"; } 2>/dev/null || true attr_lang=${attr_lang%%@*} fi;; esac attr_lang=$(printf '%s' "$attr_lang" | tr _ -) case "$attr_lang" in [A-Za-z][A-Za-z]|[A-Za-z][A-Za-z][A-Za-z]|[A-Za-z][A-Za-z]-?*|[A-Za-z][A-Za-z][A-Za-z]-?*) case "$attr_lang" in *[!A-Za-z0-9-]*|*--*|*-) attr_lang=;; esac;; *) attr_lang=;; esac [ "${#attr_lang}" -le 35 ] || attr_lang= fi if [ "$attr_doctor" -eq 1 ]; then # Human-readable and local: browser family and profile names, counts and # settings only. Never a folder path, a site folder name or a receipt. attr_markers=$(awk 'END { print NR }' "$attr_work/receipts") attr_total=$(awk 'END { print NR }' "$attr_work/roots") attr_files=$(cat "$attr_work/count") case "$attr_platform" in darwin) attr_os=macOS;; linux) attr_os=Linux;; *) attr_os='other (automatic discovery covers macOS and Linux)';; esac attr_shown=${attr_service%/} [ "$attr_service_ok" -eq 1 ] || attr_shown='not set or not https' attr_named=$attr_project [ "$attr_project_ok" -eq 1 ] || attr_named='not set (pass --project )' if [ -n "$attr_off" ]; then attr_status="off: $attr_off is set" elif [ -n "$attr_ci" ] && [ "${CLITRAIL_ALLOW_CI:-}" != 1 ]; then attr_status="off: CI detected ($attr_ci)" elif [ -z "$attr_transport" ]; then attr_status='off: neither curl nor wget is installed' elif [ "$attr_service_ok" -eq 0 ]; then attr_status='off: no https service URL (the hook served by CLItrail includes one)' elif [ "$attr_project_ok" -eq 0 ]; then attr_status='off: no website ID' elif [ "$attr_handoff_mode" -eq 1 ]; then attr_status='on: an install would send the handoff token' elif [ "$attr_markers" -eq 1 ]; then attr_status='on: an install would send 1 receipt' else attr_status="on: an install would send $attr_markers receipts"; fi # What an install would add about this machine; "not sent" names why. attr_or() { if [ -n "$1" ]; then printf '%s' "$1"; else printf 'not sent (%s)' "$2"; fi; } case "$attr_platform" in darwin) attr_asked=defaults;; linux) attr_asked='xdg-settings or xdg-mime';; *) attr_asked=;; esac if [ -n "$attr_default" ]; then attr_default_shown="$attr_default ($attr_default_from)" elif [ -n "$attr_asked" ]; then attr_default_shown="not sent (no answer from $attr_asked)" else attr_default_shown='not sent (read on macOS and Linux only)'; fi attr_path_shown='none (the service counts installs toward the website'"'"'s default install path)' [ -z "$attr_path" ] || attr_path_shown="$attr_path ($attr_path_from)" attr_families='not sent (only automatic discovery reports them)' [ "$attr_discovered" -eq 0 ] || attr_families=$(awk '!seen[$0]++ { printf "%s%s", (n++ ? ", " : ""), $0 } END { if (!n) printf "none found (sent as an empty list)" }' "$attr_work/present") printf 'CLItrail installer hook diagnostics. Nothing was sent; folder paths and receipts are never shown.\n' printf ' %-11s %s\n' System "$attr_os" Arch "$(attr_or "$attr_arch" 'uname -m named no arm64 or x86_64 CPU')" \ Timezone "$(attr_or "$attr_tz" 'no IANA time zone name found')" Language "$(attr_or "$attr_lang" 'no locale set')" \ Default "$attr_default_shown" Project "$attr_named" Path "$attr_path_shown" Service "$attr_shown" \ Transport "${attr_transport:-none (installs need curl or wget)}$([ "$attr_transport" != wget ] || printf ' (GNU wget options)')" if [ -n "$attr_off" ]; then printf ' %-11s %s\n' Opt-out "$attr_off is set: installs exit before reading anything" else printf ' %-11s %s\n' Opt-out 'none (DO_NOT_TRACK=1 or CLITRAIL_DISABLE=1 turns the hook off)'; fi if [ -z "$attr_ci" ]; then printf ' %-11s %s\n' CI 'not detected' elif [ "${CLITRAIL_ALLOW_CI:-}" = 1 ]; then printf ' %-11s %s\n' CI "detected ($attr_ci), reporting allowed by CLITRAIL_ALLOW_CI=1" else printf ' %-11s %s\n' CI "detected ($attr_ci): installs exit before reading anything (CLITRAIL_ALLOW_CI=1 overrides)"; fi if [ "${CLITRAIL_QUIET:-}" = 1 ]; then printf ' %-11s %s\n' Notice 'hidden by CLITRAIL_QUIET=1' else printf ' %-11s %s\n' Notice 'one line on stderr when it is a terminal (CLITRAIL_QUIET=1 hides it)'; fi if [ "$attr_handoff_mode" -eq 1 ]; then printf ' %-11s %s\n' Browsers 'not read: a handoff token was supplied' else # families: installed browsers; labels: "family/profile" per folder; # tally: " family/profile" per folder actually scanned. awk -v families="$attr_work/families" -v labels="$attr_work/labels" -v tally="$attr_work/tally" ' function clean(s) { gsub(/[^A-Za-z0-9 ._()+-]/, "?", s); return substr(s, 1, 64) } function count(n, word) { return n " " word (n == 1 ? "" : "s") } function add(f) { if (!(f in known)) { known[f] = 1; order[++total] = f } } FILENAME == families { add($0); next } FILENAME == labels { f = substr($0, 1, index($0, "/") - 1); add(f) if (!($0 in folders)) keys[f] = keys[f] SUBSEP $0 folders[$0]++; next } FILENAME == tally { key = substr($0, index($0, " ") + 1); scanned[key]++ if ($1 != "-") { counted[key] = 1; marks[key] += $1 }; next } END { prefix = sprintf(" %-11s ", "Browsers") if (!total) print prefix "none found (Chrome, Chromium, Edge, Brave, Vivaldi, Firefox and, on Linux, Zen are checked; Safari, Arc and Opera never)" for (i = 1; i <= total; i++) { f = order[i]; n = split(substr(keys[f], 2), list, SUBSEP); line = "" for (j = 1; j <= n; j++) { key = list[j]; p = clean(substr(key, index(key, "/") + 1)) if (!(key in scanned)) state = "not scanned (time or file limit)" else if (!(key in counted)) state = "not counted" else state = count(marks[key], "marker") if (folders[key] > 1) state = state " in " count(folders[key], "site folder") if ((key in scanned) && scanned[key] < folders[key]) state = state ", partly scanned" line = line (j > 1 ? ", " : "") (p == "" ? state : p " (" state ")") } print prefix clean(f) ": " (n ? line : "found, no site storage yet") prefix = sprintf(" %-11s ", "") } }' "$attr_work/families" "$attr_work/labels" "$attr_work/tally" fi printf ' %-11s %s\n' Families "$attr_families" attr_plural() { if [ "$1" -eq 1 ]; then printf '%s %s' "$1" "$2"; else printf '%s %ss' "$1" "$2"; fi; } attr_limit= [ ! -f "$attr_work/stop" ] || attr_limit=', stopped at the time or file limit' printf ' %-11s %s\n' Scanned "$attr_roots of $(attr_plural "$attr_total" folder), $(attr_plural "$attr_files" file)$attr_limit" if [ "$attr_handoff_mode" -eq 1 ]; then printf ' %-11s %s\n' Markers 'none read (handoff)' elif [ "$attr_project_ok" -eq 1 ]; then printf ' %-11s %s\n' Markers "$attr_markers for this project" else printf ' %-11s %s\n' Markers 'not counted without a website ID'; fi printf ' %-11s %s\n' Status "$attr_status" exit 0 fi if [ "$attr_dry" -eq 1 ]; then awk -v "roots=$attr_roots" -v "checked=$(cat "$attr_work/count")" \ -v "stopped=$([ -f "$attr_work/stop" ] && printf true || printf false)" \ 'END { printf "{\"roots\":%d,\"markers\":%d,\"files_checked\":%d,\"truncated\":%s}\n",roots,NR,checked,stopped }' \ "$attr_work/receipts" exit 0 fi attr_random() { od -An -N16 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n'; } attr_hex_id() { [ "${#1}" -eq 32 ] && case "$1" in *[!0-9a-f]*) return 1;; *) return 0;; esac; } attr_service_key=$(printf '%s' "$attr_service" | tr -c 'A-Za-z0-9._-' '_') attr_state="$attr_state/$attr_service_key/$attr_project" attr_safe_path "$attr_state" || exit 0 mkdir -p "$attr_state" 2>/dev/null || exit 0 attr_safe_path "$attr_state/$attr_scope.id" || exit 0 if [ ! -e "$attr_state/$attr_scope.id" ]; then attr_id= # One-time copy of an ID an older hook kept, so that install is not counted # twice. The old file stays for older hooks bundled in other packages. attr_old="$attr_legacy/$attr_service_key/$attr_project/$attr_scope.id" if [ -n "$attr_legacy" ] && [ -f "$attr_old" ] && attr_safe_path "$attr_old"; then { IFS= read -r attr_id < "$attr_old"; } 2>/dev/null attr_hex_id "$attr_id" || attr_id= fi [ -n "$attr_id" ] || attr_id=$(attr_random) attr_hex_id "$attr_id" || exit 0 # Noclobber makes simultaneous first reports converge on the same state file. (set -C; printf '%s\n' "$attr_id" > "$attr_state/$attr_scope.id") 2>/dev/null || true fi IFS= read -r attr_id < "$attr_state/$attr_scope.id" || exit 0 attr_hex_id "$attr_id" || exit 0 if [ -z "$attr_event_id" ]; then attr_event_id=$(attr_random); fi case "$attr_event_id" in *[!A-Za-z0-9_-]*) exit 0;; esac [ "${#attr_event_id}" -ge 8 ] && [ "${#attr_event_id}" -le 128 ] || exit 0 { printf '{"project":"%s","event_id":"%s","installation_id":"%s","event_type":"%s","receipts":[' \ "$attr_project" "$attr_event_id" "$attr_id" "$attr_event" awk '{ printf "%s\"%s\"", (NR>1 ? "," : ""), $0 }' "$attr_work/receipts" printf ']' [ -z "$attr_handoff" ] || printf ',"handoff":"%s"' "$attr_handoff" [ -z "$attr_platform" ] || printf ',"platform":"%s"' "$attr_platform" # Every value below was checked against a fixed character set above. [ -z "$attr_path" ] || printf ',"install_path":"%s"' "$attr_path" [ -z "$attr_arch" ] || printf ',"arch":"%s"' "$attr_arch" if [ "$attr_discovered" -eq 1 ]; then printf ',"browsers":[' awk '!seen[$0]++ { printf "%s\"%s\"", (n++ ? "," : ""), $0 }' "$attr_work/present" printf ']' fi [ -z "$attr_default" ] || printf ',"default_browser":"%s"' "$attr_default" [ -z "$attr_tz" ] || printf ',"timezone":"%s"' "$attr_tz" [ -z "$attr_lang" ] || printf ',"language":"%s"' "$attr_lang" printf '}' } > "$attr_work/request" if [ "$attr_transport" = curl ]; then curl -q -fsS --connect-timeout 3 --max-time 5 -H 'Content-Type: application/json' \ --data-binary "@$attr_work/request" "${attr_service%/}/v1/events" >/dev/null 2>&1 || true else # GNU wget flags: one attempt, bounded network inactivity, no redirects. # Wget builds lacking these options simply fail without affecting installation. wget --no-config -q -T 5 -t 1 --max-redirect=0 -O /dev/null --header='Content-Type: application/json' \ --post-file="$attr_work/request" "${attr_service%/}/v1/events" >/dev/null 2>&1 || true fi exit 0