Files
lightcraft/tools/install-sam3.sh
T
Dylan M. TaylorandCodebuff 4ae3089420 security: bounded keyword, repair and journal-recovery work (CodeRabbit) (#2763)
* security: harden dev-tool scripts and the testkit skip-log test (CodeRabbit)

- tools/install-sam3.sh: the HF bearer token went through curl's argv, where
  `ps` exposes it to other local accounts (CWE-214); it now travels through
  a private 0600 temp config file removed on exit.
- packaging/macos/import-cert.sh: the decoded .p12 was written to a
  predictable path in a shared TMPDIR, where a precreated attacker-readable
  file would receive it (CWE-377); both cert and keychain are created inside
  a mktemp -d 0700 directory now.
- docs/showcase/run.py: the reply reader copied whole responses with no size
  cap (obvious quadratic-copy and unbounded-memory DoS). It accumulates in a
  bytearray now and closes the connection past 128 MiB.
- tools/spectral_camera_table.py: alias/make/model strings and comments from
  the rawtoaces-data checkout moved directly into Rust source; one newline in
  a comment became Rust code. External text is now escaped as Rust literal
  content and comments are normalized to one printable line.
- crates/testkit: appended_lines_accumulate reused a pip-predictable temp
  dir by creating it with create_dir_all and carried on after failing to
  remove its log path, so the append would follow an attacker-planted
  symlink (CWE-59); it now fails the test if the directory exists instead of
  testing through it, and removes any existing log path only after clearly
  checking it, always failing the test instead of proceeding when removal fails.

Findings #10080/#10078 PR (rows 2, 6, 8, 9, 15 of the
storytold-lightcraft-agent-findings report).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* security: bounded catalog keyword, repair, journal recovery work (CodeRabbit)

- keywords: from_file searched every hierarchical level for each flat keyword
  and `same` lower-cased two strings per comparison — one lowered level set
  instead (CWE-407, row 97).
- keywords: parse_keyword_list scanned each keyword's growing synonym list per
  line — a lowered-synonym set per keyword instead (CWE-407, row 100).
- keywords: keyword_tree stored every level's prefix twice, so a photo XMP
  keyword nested deeper than a list file accepts made stored bytes quadratic
  in the level count; the tree caps at MAX_LEVELS, and keyword_list_text names
  whatever lies deeper from the photos' and list's own spellings, so the file
  still reads back (CWE-400, row 103).
- catalog: repair validated a folder chain once per album — memoized by chain
  now, so a valid chain in a flat snapshot costs one walk overall (CWE-407,
  row 101).
- store: journal append and truncate follow a planted catalog.log symlink;
  both refuse any non-regular file at the name before writing through it
  (CWE-59, row 107), like the staging writes since #1502.
- journal: resync re-validated the whole remaining suffix UTF-8 for every
  candidate record start; a whole line is validated once (covering every
  candidate in it), and an invalid line's re-checks stop at a 64 MiB budget,
  with the tail then staying a torn one the damaged path keeps and reports
  (CWE-407, row 109).

New tests: from_file case-insensitive level match, synonyms kept once, a
4096-level keyword cut at MAX_LEVELS, a link at catalog.log refused, and
resync over a fragment of 1024 record starts.

Findings rows 97, 100, 101, 103, 107, 109 of the
storytold-lightcraft-agent-findings report.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-23 22:45:21 -04:00

146 lines
6.0 KiB
Bash
Executable File

#!/bin/sh
# Install the SAM 3 model for LightCraft's Object and Describe masks (see docs/ai-masks.md).
#
# The desktop app offers to download the model itself when an AI mask first needs it; this script
# is for developers (reference tests, benchmarks) and for installing from Hugging Face directly.
#
# The weights are not part of LightCraft: they are Meta's facebook/sam3 checkpoint on Hugging
# Face, under the SAM License. Access is gated: open https://huggingface.co/facebook/sam3, accept
# the license, wait for approval, then run this with a token that can read it:
#
# HF_TOKEN=hf_... tools/install-sam3.sh
#
# (or log in once with `hf auth login`; the token it saves is used). Downloads resume when
# interrupted, and the 3.4 GB weights are checked against the official SHA-256.
#
# Options:
# --dir DIR where to install (default: the folder LightCraft looks in; or LIGHTCRAFT_SAM3_DIR)
# --repo NAME another Hugging Face repo with the same files (default: facebook/sam3)
# --check only verify an existing installation
set -eu
REPO="facebook/sam3"
CHECK_ONLY=0
SHA256="6d06f0a5f84e435071fe6603e61d0b4cc7b40e0d39d487cfd4d67d8cc11cc14a"
SIZE=3439938512
FILES="config.json vocab.json merges.txt tokenizer.json tokenizer_config.json special_tokens_map.json processor_config.json"
case "$(uname -s)" in
Darwin) DEFAULT_DIR="$HOME/Library/Application Support/LightCraft/models/sam3" ;;
*) DEFAULT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/lightcraft/models/sam3" ;;
esac
DIR="${LIGHTCRAFT_SAM3_DIR:-$DEFAULT_DIR}"
while [ $# -gt 0 ]; do
case "$1" in
--dir) DIR="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--check) CHECK_ONLY=1; shift ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $1 (see --help)" >&2; exit 2 ;;
esac
done
sha256() {
sha_file=$1
if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$sha_file" | cut -d' ' -f1
else sha256sum "$sha_file" | cut -d' ' -f1; fi
}
verify() {
for f in model.safetensors vocab.json merges.txt; do
[ -s "$DIR/$f" ] || { echo "missing: $DIR/$f" >&2; return 1; }
done
echo "checking model.safetensors (SHA-256 of 3.4 GB, takes a few seconds)…"
got=$(sha256 "$DIR/model.safetensors")
if [ "$got" != "$SHA256" ]; then
echo "model.safetensors does not match the official checkpoint (got $got)" >&2
return 1
fi
echo "SAM 3 is installed in $DIR"
}
if [ "$CHECK_ONLY" = 1 ]; then verify; exit $?; fi
command -v curl >/dev/null 2>&1 || { echo "curl is required" >&2; exit 1; }
TOKEN="${HF_TOKEN:-${HUGGING_FACE_HUB_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
for t in "${HF_HOME:-$HOME/.cache/huggingface}/token" "$HOME/.huggingface/token"; do
[ -s "$t" ] && TOKEN=$(cat "$t") && break
done
fi
if [ -z "$TOKEN" ] && [ "$REPO" = "facebook/sam3" ]; then
echo "No Hugging Face token found. facebook/sam3 is gated: accept the license at" >&2
echo "https://huggingface.co/facebook/sam3, then set HF_TOKEN or run \`hf auth login\`." >&2
exit 1
fi
mkdir -p "$DIR"
BASE="https://huggingface.co/$REPO/resolve/main"
# The bearer header goes through a private temporary file, never argv: on systems that expose other
# users' process arguments another local account could read the token from `ps` (CWE-214), but not a
# 0600 file. curl's config format: `header = "..."`.
if [ -n "$TOKEN" ]; then
AUTH_FILE=$(mktemp "${TMPDIR:-/tmp}/lightcraft-sam3-auth.XXXXXX")
trap 'rm -f "$AUTH_FILE"' EXIT
printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" > "$AUTH_FILE"
fi
# fetch FILE DEST [resume]: HTTP errors never reach DEST (--fail writes no error page), and a
# resume the server refuses (416) starts over instead of counting as done.
fetch() {
file=$1 dest=$2 resume=${3:-}
if [ -n "$TOKEN" ]; then
CURL_AUTH=(--config "$AUTH_FILE")
else
# not secret (no token is sent); kept in argv only to mirror the authenticated call shape
CURL_AUTH=(-H "X-No-Auth: 1")
fi
code=$(curl --proto '=https' -fsSL ${resume:+-C -} "${CURL_AUTH[@]}" -w '%{http_code}' -o "$dest" "$BASE/$file") || true
case "$code" in
200|206) return 0 ;;
416)
echo "the partial download of $file doesn't match the server's; starting over" >&2
rm -f "$dest"
code=$(curl --proto '=https' -fsSL "${CURL_AUTH[@]}" -w '%{http_code}' -o "$dest" "$BASE/$file") || true
[ "$code" = 200 ] && return 0
echo "download of $REPO/$file failed ($code)" >&2; return 1 ;;
401|403) echo "access denied to $REPO/$file (has your request for facebook/sam3 been approved?)" >&2; return 1 ;;
*) echo "download of $REPO/$file failed (${code:-no response})" >&2; return 1 ;;
esac
}
for f in $FILES; do
echo "fetching $f"
# small files: whole, into place only when complete
fetch "$f" "$DIR/$f.part" && mv -f "$DIR/$f.part" "$DIR/$f" || { rm -f "$DIR/$f.part"; exit 1; }
done
if [ -s "$DIR/model.safetensors" ] && [ "$(sha256 "$DIR/model.safetensors")" = "$SHA256" ]; then
echo "model.safetensors already installed"
else
echo "fetching model.safetensors (3.4 GB; resumes if interrupted)"
part="$DIR/model.safetensors.part"
# a wrong model.safetensors is never resumed from: it goes
rm -f "$DIR/model.safetensors"
if [ -f "$part" ] && [ "$(wc -c < "$part" | tr -d ' ')" -ge "$SIZE" ]; then rm -f "$part"; fi
fetch model.safetensors "$part" resume || exit 1
have=$(wc -c < "$part" | tr -d ' ')
if [ "$have" -lt "$SIZE" ]; then
echo "incomplete download ($have of $SIZE bytes); run the script again to resume" >&2
exit 1
fi
echo "checking model.safetensors (SHA-256 of 3.4 GB, takes a few seconds)…"
if [ "$have" != "$SIZE" ] || [ "$(sha256 "$part")" != "$SHA256" ]; then
rm -f "$part"
echo "the download is damaged or not the official checkpoint; deleted it, run the script again" >&2
exit 1
fi
mv -f "$part" "$DIR/model.safetensors"
fi
verify
echo "Restart LightCraft; Object and Describe in the Masking panel now use it."