Merge pull request 'fix: safe uploads, AI history continuity, deep search, sudo credentials' (#55) from fix/safe-uploads-history-search-sudo into master
Some checks failed
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 6s
Test / rust-fmt-check (push) Successful in 1m18s
Test / frontend-tests (push) Successful in 1m27s
Test / frontend-typecheck (push) Successful in 1m30s
Auto Tag / changelog (push) Failing after 1m27s
Auto Tag / build-macos-arm64 (push) Successful in 2m45s
Test / rust-clippy (push) Successful in 3m12s
Test / rust-tests (push) Successful in 5m22s
Auto Tag / build-linux-amd64 (push) Successful in 10m55s
Auto Tag / build-windows-amd64 (push) Successful in 13m53s
Auto Tag / build-linux-arm64 (push) Successful in 14m11s

Reviewed-on: #55
This commit is contained in:
sarman 2026-05-31 20:52:31 +00:00
commit d3dfa41d83
14 changed files with 1574 additions and 53 deletions

View File

@ -4,9 +4,6 @@ on:
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened, edited]
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
review: review:
@ -21,7 +18,7 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
apt-get update -qq && apt-get install -y -qq git curl jq apt-get update -qq && apt-get install -y -qq git curl jq python3
- name: Checkout code - name: Checkout code
shell: bash shell: bash
@ -34,18 +31,114 @@ jobs:
git fetch --depth=1 origin ${{ github.head_ref }} git fetch --depth=1 origin ${{ github.head_ref }}
git checkout FETCH_HEAD git checkout FETCH_HEAD
- name: Get PR diff - name: Build review context
id: diff id: context
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
git fetch origin ${{ github.base_ref }} git fetch origin ${{ github.base_ref }}
git diff origin/${{ github.base_ref }}..HEAD > /tmp/pr_diff.txt
echo "diff_size=$(wc -l < /tmp/pr_diff.txt | tr -d ' ')" >> $GITHUB_OUTPUT # List changed source files (exclude generated/lock files)
git diff --name-only origin/${{ github.base_ref }}..HEAD \
-- ':!Cargo.lock' ':!package-lock.json' ':!*.lock' \
> /tmp/pr_files.txt
FILE_COUNT=$(wc -l < /tmp/pr_files.txt | tr -d ' ')
echo "files_changed=${FILE_COUNT}" >> $GITHUB_OUTPUT
if [ "$FILE_COUNT" -eq 0 ]; then
echo "No reviewable files changed."
echo "diff_size=0" >> $GITHUB_OUTPUT
exit 0
fi
# Build context: full file content for each changed file.
# Files <= 500 lines: include complete content.
# Files > 500 lines: include the per-file diff with generous context (±50 lines).
#
# Secret scrubbing: match actual credential VALUES only — known API key formats,
# or keyword="long_quoted_literal" (25+ chars). Never scrub on keyword alone,
# which would silently delete function signatures, variable declarations, and tests.
SECRET_PATTERN='AKIA[A-Z0-9]{16}|gh[opsu]_[A-Za-z0-9_]{36,}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}|(password|token|api_key|secret)[[:space:]]*=[[:space:]]*["'"'"'][A-Za-z0-9+/_!@#-]{25,}["'"'"']'
# Only strip lines that are ENTIRELY a long base64 blob (e.g. PEM cert bodies)
B64_PATTERN='^[[:space:]]*[A-Za-z0-9+/]{60,}={0,2}[[:space:]]*$'
> /tmp/pr_context.txt
while IFS= read -r file; do
[ -f "$file" ] || continue
lines=$(wc -l < "$file" | tr -d ' ')
printf '\n════════ FILE: %s (%s lines) ════════\n' "$file" "$lines" >> /tmp/pr_context.txt
if [ "$lines" -le 500 ]; then
# Full file — model sees the complete implementation
grep -v -E "$SECRET_PATTERN" "$file" \
| grep -v -E "$B64_PATTERN" \
>> /tmp/pr_context.txt || true
else
# Large file — emit annotated diff hunks (±50 lines of context each)
printf '[File too large for full view (%s lines) — showing changed sections only]\n' "$lines" >> /tmp/pr_context.txt
git diff -U50 origin/${{ github.base_ref }}..HEAD -- "$file" \
| grep -v -E "$SECRET_PATTERN" \
| grep -v -E "$B64_PATTERN" \
>> /tmp/pr_context.txt || true
fi
done < /tmp/pr_files.txt
TOTAL=$(wc -l < /tmp/pr_context.txt | tr -d ' ')
echo "diff_size=${TOTAL}" >> $GITHUB_OUTPUT
# Cap at 6000 lines so we stay within the model's context window
if [ "$TOTAL" -gt 6000 ]; then
head -n 6000 /tmp/pr_context.txt > /tmp/pr_context_capped.txt
mv /tmp/pr_context_capped.txt /tmp/pr_context.txt
echo "[CONTEXT TRUNCATED at 6000 lines — ${TOTAL} total]" >> /tmp/pr_context.txt
fi
- name: Build codebase index
id: index
if: steps.context.outputs.diff_size != '0'
shell: bash
run: |
set -euo pipefail
# Build a compact index of everything that EXISTS in this codebase.
# Included in the prompt so the model cannot invent functions/commands/tables
# that are not present — any finding referencing something absent from this
# index is immediately suspect.
{
echo "## CODEBASE INDEX"
echo "These are the ONLY Tauri commands, TypeScript exports, Rust public functions,"
echo "and database tables that exist in this project. Before raising any finding,"
echo "confirm that every symbol you cite appears in this list or in the file"
echo "contents below. If it does not appear in either, your finding is fabricated."
echo ""
echo "### Registered Tauri commands (lib.rs generate_handler![]):"
grep -oE 'commands::[a-z_]+::[a-z_]+' src-tauri/src/lib.rs 2>/dev/null \
| sort -u | sed 's/^/ /' || true
echo ""
echo "### TypeScript invoke wrappers (src/lib/tauriCommands.ts):"
grep -E '^export (const|interface|type) ' src/lib/tauriCommands.ts 2>/dev/null \
| sed 's/^/ /' || true
echo ""
echo "### Public Rust functions in src-tauri/src/commands/:"
grep -rh --include='*.rs' '^pub ' src-tauri/src/commands/ 2>/dev/null \
| grep 'fn ' | sed 's/^/ /' | sort || true
echo ""
echo "### Database tables (src-tauri/src/db/migrations.rs):"
grep -oE '"[0-9]+_[a-z_]+"' src-tauri/src/db/migrations.rs 2>/dev/null \
| tr -d '"' | sed 's/^/ /' || true
echo ""
} > /tmp/codebase_index.txt
INDEX_LINES=$(wc -l < /tmp/codebase_index.txt | tr -d ' ')
echo "index_lines=${INDEX_LINES}" >> $GITHUB_OUTPUT
echo "Built codebase index: ${INDEX_LINES} lines"
- name: Analyze with LLM - name: Analyze with LLM
id: analyze id: analyze
if: steps.diff.outputs.diff_size != '0' if: steps.context.outputs.diff_size != '0'
shell: bash shell: bash
env: env:
LITELLM_URL: http://172.0.0.29:11434/v1 LITELLM_URL: http://172.0.0.29:11434/v1
@ -54,25 +147,62 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
run: | run: |
set -euo pipefail set -euo pipefail
if grep -q "^Binary files" /tmp/pr_diff.txt; then CHANGED_FILES=$(tr '\n' ' ' < /tmp/pr_files.txt)
echo "WARNING: Binary file changes detected — they will be excluded from analysis"
fi # Build prompt file. Use 'printf "%s\n" text' throughout so the format
DIFF_CONTENT=$(head -n 500 /tmp/pr_diff.txt \ # string is always "%s\n" and content with leading hyphens or embedded
| grep -v -E '^[+-].*(password[[:space:]]*[=:"'"'"']|token[[:space:]]*[=:"'"'"']|secret[[:space:]]*[=:"'"'"']|api_key[[:space:]]*[=:"'"'"']|private_key[[:space:]]*[=:"'"'"']|Authorization:[[:space:]]|AKIA[A-Z0-9]{16}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}|gh[opsu]_[A-Za-z0-9_]{36,}|https?://[^@[:space:]]+:[^@[:space:]]+@)' \ # double-dashes is never misinterpreted as a printf option flag.
| grep -v -E '^[+-].*[A-Za-z0-9+/]{40,}={0,2}([^A-Za-z0-9+/=]|$)') {
PROMPT="You are a senior engineer performing a focused code review. Your review must be grounded strictly in the diff provided — do not invent issues about code you cannot see.\n\nPR Title: ${PR_TITLE}\n\nDiff:\n${DIFF_CONTENT}\n\n## Instructions\n\n1. **Read the entire diff first.** Before raising any issue, verify it against the actual lines in the diff. If something appears to be missing, confirm it is absent from ALL relevant files in the diff before claiming it is missing.\n\n2. **Quote the evidence.** For every issue you raise, cite the specific file and line from the diff that supports your claim. If you cannot quote a line, do not raise the issue.\n\n3. **Distinguish severity clearly:**\n - BLOCKER: broken right now, will cause crashes or data loss\n - WARNING: works but has a real risk that should be addressed before merge\n - SUGGESTION: improvement worth considering in a follow-up PR\n\n4. **Do not raise issues about code outside the diff.** If a concern requires reading files not present in the diff, say 'outside the scope of this diff' and skip it.\n\n5. **Keep it concise.** Lead with a one-paragraph summary, then list only genuine findings with evidence. Avoid restating what the code already does correctly unless it is directly relevant to a finding.\n\n## Output format\n\n**Summary** (1 paragraph)\n\n**Findings** (only real issues with quoted evidence)\n- [BLOCKER/WARNING/SUGGESTION] filename:line — description and suggested fix\n\n**Verdict**: APPROVE / APPROVE WITH COMMENTS / REQUEST CHANGES" printf '%s\n\n' 'You are a senior engineer performing a code review.'
BODY=$(jq -cn \ printf 'PR Title: %s\n' "$PR_TITLE"
printf 'Files changed: %s\n\n' "$CHANGED_FILES"
printf '%s\n' '---'
cat /tmp/codebase_index.txt
printf '%s\n\n' '---'
printf '%s\n\n' '## Changed file contents'
printf '%s\n' 'Each section is the COMPLETE, FINAL file after PR changes (not a diff).'
printf '%s\n\n' 'Files over 500 lines show only changed sections with surrounding context.'
printf '%s\n' '---'
cat /tmp/pr_context.txt
printf '%s\n\n' '---'
printf '%s\n\n' '## Instructions'
printf '%s\n' 'Before raising any finding:'
printf '%s\n' '1. Confirm every symbol you cite exists in the CODEBASE INDEX or file'
printf '%s\n' ' contents above. If absent from both, discard the finding.'
printf '%s\n' '2. Quote the exact line(s) from the file contents that support it.'
printf '%s\n' '3. Confirm the issue is genuine, not intentional design.'
printf '%s\n\n' '4. If any step fails, discard silently - do not mention it.'
printf '%s\n\n' 'Do NOT show reasoning. Only output confirmed issues.'
printf '%s\n' 'Severity:'
printf '%s\n' '- BLOCKER: fails to compile, corrupts data, or security vulnerability'
printf '%s\n' '- WARNING: real risk to address before merge'
printf '%s\n\n' '- SUGGESTION: minor improvement, follow-up PR fine'
printf '%s\n\n' 'Focus: security bugs, logic errors, data loss, injection, unhandled errors.'
printf '%s\n\n' 'Ignore: style, missing comments, speculative future concerns.'
printf '%s\n\n' '## Output format (strict)'
printf '%s\n\n' '**Summary** (2-3 sentences)'
printf '%s\n' '**Findings**'
printf '%s\n' '- [SEVERITY] file:line - description'
printf '%s\n' ' Evidence: quoted line'
printf '%s\n\n' ' Fix: concrete change'
printf '%s\n\n' '(Write "No findings." if none.)'
printf '%s\n' '**Verdict**: APPROVE / APPROVE WITH COMMENTS / REQUEST CHANGES'
} > /tmp/prompt.txt
# Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX.
jq -cn \
--arg model "qwen3-coder-next" \ --arg model "qwen3-coder-next" \
--arg content "$PROMPT" \ --rawfile content /tmp/prompt.txt \
'{model: $model, messages: [{role: "user", content: $content}], stream: false}') '{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling liteLLM API (${#BODY} bytes)..." > /tmp/body.json
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PR #${PR_NUMBER} - Calling liteLLM API ($(wc -c < /tmp/body.json) bytes)..."
HTTP_CODE=$(curl -s --max-time 300 --connect-timeout 30 \ HTTP_CODE=$(curl -s --max-time 300 --connect-timeout 30 \
--retry 3 --retry-delay 10 --retry-connrefused --retry-max-time 300 \ --retry 3 --retry-delay 10 --retry-connrefused --retry-max-time 300 \
-o /tmp/llm_response.json -w "%{http_code}" \ -o /tmp/llm_response.json -w "%{http_code}" \
-X POST "$LITELLM_URL/chat/completions" \ -X POST "$LITELLM_URL/chat/completions" \
-H "Authorization: Bearer $LITELLM_API_KEY" \ -H "Authorization: Bearer $LITELLM_API_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$BODY") --data @/tmp/body.json)
echo "HTTP status: $HTTP_CODE" echo "HTTP status: $HTTP_CODE"
echo "Response file size: $(wc -c < /tmp/llm_response.json) bytes" echo "Response file size: $(wc -c < /tmp/llm_response.json) bytes"
if [ "$HTTP_CODE" != "200" ]; then if [ "$HTTP_CODE" != "200" ]; then
@ -93,8 +223,77 @@ jobs:
echo "Review length: ${#REVIEW} chars" echo "Review length: ${#REVIEW} chars"
echo "$REVIEW" > /tmp/pr_review.txt echo "$REVIEW" > /tmp/pr_review.txt
- name: Verify findings against codebase
if: steps.analyze.outcome == 'success'
shell: bash
run: |
set -euo pipefail
# For each finding that contains a fenced code block under "Evidence:",
# grep at least one substantial line of that block against the FULL repository.
# Searching the full repo (not just changed files) prevents false UNVERIFIED
# tags when the model correctly quotes unchanged files, while still flagging
# fabricated code that doesn't exist anywhere in the codebase.
python3 - << 'PYEOF'
import re, os, subprocess
review = open('/tmp/pr_review.txt').read()
# Load ENTIRE tracked repository (all .rs, .ts, .tsx, .yml, .toml, .json files)
result = subprocess.run(
['git', 'ls-files', '--',
'*.rs', '*.ts', '*.tsx', '*.yml', '*.yaml', '*.toml', '*.json', '*.sql'],
capture_output=True, text=True
)
all_tracked = [f.strip() for f in result.stdout.splitlines() if f.strip()]
all_content_parts = []
for path in all_tracked:
if os.path.isfile(path):
try:
all_content_parts.append(open(path).read())
except Exception:
pass
all_content = '\n'.join(all_content_parts)
def evidence_exists(block: str) -> bool:
"""True if ≥1 significant line from the block is found verbatim in changed files."""
for raw in block.splitlines():
line = raw.lstrip('+-').strip()
# Skip blank, very short, pure-comment, or diff-header lines
if len(line) < 20:
continue
if line.startswith(('//','#','/*','*','Fix:','Evidence:','---','+++')):
continue
if line in all_content:
return True
return False
# Split on finding markers; re-join after optional tagging
severity_re = re.compile(r'\[(BLOCKER|WARNING|SUGGESTION)\]')
def tag_if_unverified(finding_text: str) -> str:
code_match = re.search(r'```[^\n]*\n(.*?)```', finding_text, re.DOTALL)
if code_match and not evidence_exists(code_match.group(1)):
# Replace first severity tag with a prefixed version
return severity_re.sub(
lambda m: f'[{m.group(1)} — ⚠️ UNVERIFIED: evidence not found in PR files]',
finding_text, count=1
)
return finding_text
# Split review into preamble + individual finding blocks
# Each block starts at a severity marker line
parts = re.split(r'(?=^\[(?:BLOCKER|WARNING|SUGGESTION)\])', review, flags=re.MULTILINE)
result = parts[0] # preamble (Summary, etc.)
for block in parts[1:]:
result += tag_if_unverified(block)
open('/tmp/pr_review.txt', 'w').write(result)
print(f"Verification complete — {len(parts)-1} finding(s) checked.")
PYEOF
- name: Post review comment - name: Post review comment
if: always() && steps.diff.outputs.diff_size != '0' if: always() && steps.context.outputs.diff_size != '0'
shell: bash shell: bash
env: env:
TF_TOKEN: ${{ secrets.TFT_GITEA_TOKEN }} TF_TOKEN: ${{ secrets.TFT_GITEA_TOKEN }}
@ -109,7 +308,7 @@ jobs:
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt) REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
BODY=$(jq -n \ BODY=$(jq -n \
--arg body "Automated PR Review (qwen3-coder-next via liteLLM):\n\n${REVIEW_BODY}\n\n---\n*automated code review*" \ --arg body "Automated PR Review (qwen3-coder-next via liteLLM):\n\n${REVIEW_BODY}" \
'{body: $body, event: "COMMENT"}') '{body: $body, event: "COMMENT"}')
else else
BODY=$(jq -n \ BODY=$(jq -n \
@ -131,4 +330,4 @@ jobs:
- name: Cleanup - name: Cleanup
if: always() if: always()
shell: bash shell: bash
run: rm -f /tmp/pr_diff.txt /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json run: rm -f /tmp/pr_diff.txt /tmp/pr_context.txt /tmp/codebase_index.txt /tmp/prompt.txt /tmp/body.json /tmp/llm_response.json /tmp/pr_review.txt /tmp/review_post_response.json /tmp/pr_files.txt

163
src-tauri/Cargo.lock generated
View File

@ -355,6 +355,26 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bzip2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
dependencies = [
"bzip2-sys",
"libc",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.18.5" version = "0.18.5"
@ -429,6 +449,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@ -708,6 +730,25 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@ -1188,6 +1229,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "elliptic-curve" name = "elliptic-curve"
version = "0.13.8" version = "0.13.8"
@ -2467,7 +2514,7 @@ dependencies = [
"hmac", "hmac",
"iterator-sorted", "iterator-sorted",
"k256", "k256",
"pbkdf2", "pbkdf2 0.12.2",
"rand 0.8.5", "rand 0.8.5",
"scrypt", "scrypt",
"serde", "serde",
@ -2588,6 +2635,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.91" version = "0.3.91"
@ -2818,13 +2875,16 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a"
dependencies = [ dependencies = [
"chrono",
"encoding_rs", "encoding_rs",
"flate2", "flate2",
"itoa", "itoa",
"linked-hash-map", "linked-hash-map",
"log", "log",
"md5", "md5",
"nom",
"pom", "pom",
"rayon",
"time", "time",
"weezl", "weezl",
] ]
@ -2938,6 +2998,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "minisign-verify" name = "minisign-verify"
version = "0.2.5" version = "0.2.5"
@ -3122,6 +3188,16 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -3442,6 +3518,17 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "password-hash"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@ -3460,6 +3547,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest",
"hmac",
"password-hash",
"sha2",
]
[[package]] [[package]]
name = "pbkdf2" name = "pbkdf2"
version = "0.12.2" version = "0.12.2"
@ -4161,6 +4260,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@ -4650,7 +4769,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [ dependencies = [
"pbkdf2", "pbkdf2 0.12.2",
"salsa20", "salsa20",
"sha2", "sha2",
] ]
@ -6252,8 +6371,10 @@ dependencies = [
"hex", "hex",
"infer 0.15.0", "infer 0.15.0",
"lazy_static", "lazy_static",
"lopdf",
"mockito", "mockito",
"printpdf", "printpdf",
"quick-xml 0.36.2",
"rand 0.8.5", "rand 0.8.5",
"regex", "regex",
"reqwest 0.12.28", "reqwest 0.12.28",
@ -6278,6 +6399,7 @@ dependencies = [
"urlencoding", "urlencoding",
"uuid", "uuid",
"warp", "warp",
"zip 0.6.6",
] ]
[[package]] [[package]]
@ -7804,10 +7926,18 @@ version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [ dependencies = [
"aes",
"byteorder", "byteorder",
"bzip2",
"constant_time_eq 0.1.5",
"crc32fast", "crc32fast",
"crossbeam-utils", "crossbeam-utils",
"flate2", "flate2",
"hmac",
"pbkdf2 0.11.0",
"sha1",
"time",
"zstd",
] ]
[[package]] [[package]]
@ -7848,6 +7978,35 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "zstd"
version = "0.11.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "5.0.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
dependencies = [
"libc",
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.1" version = "0.5.1"

View File

@ -45,6 +45,9 @@ warp = "0.3"
urlencoding = "2" urlencoding = "2"
infer = "0.15" infer = "0.15"
url = "2.5.8" url = "2.5.8"
lopdf = "0.31"
zip = "0.6"
quick-xml = "0.36"
rmcp = { version = "1.7.0", features = [ rmcp = { version = "1.7.0", features = [
"client", "client",
"transport-child-process", "transport-child-process",

View File

@ -0,0 +1,123 @@
use std::io::Write;
use std::process::{Command, Stdio};
#[derive(Debug, serde::Serialize)]
pub struct SudoOutput {
pub stdout: String,
pub stderr: String,
pub success: bool,
pub exit_code: Option<i32>,
}
/// Execute a command via sudo, passing the password via stdin (never via cmdline args).
/// `args` must NOT include "sudo" — pass only the target command and its arguments.
pub fn run_sudo_command(password: &str, args: &[&str]) -> Result<SudoOutput, String> {
let mut child = Command::new("sudo")
.arg("-S") // read password from stdin
.arg("-p")
.arg("") // suppress prompt text
.arg("--") // end of sudo options — prevents injection
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn sudo: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
writeln!(stdin, "{password}")
.map_err(|e| format!("Failed to write password to stdin: {e}"))?;
}
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for sudo: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = strip_sudo_password_prompt(&String::from_utf8_lossy(&output.stderr));
Ok(SudoOutput {
stdout,
stderr,
success: output.status.success(),
exit_code: output.status.code(),
})
}
/// Strip "[sudo] password for ..." prompt lines from stderr before logging.
fn strip_sudo_password_prompt(text: &str) -> String {
text.lines()
.filter(|line| {
let lower = line.to_lowercase();
!lower.contains("[sudo] password") && !lower.starts_with("password:")
})
.collect::<Vec<_>>()
.join("\n")
}
/// Like run_sudo_command but writes a sanitized audit entry first.
/// The password is NEVER included in audit details.
pub fn run_sudo_command_audited(
password: &str,
args: &[&str],
db: &rusqlite::Connection,
) -> Result<SudoOutput, String> {
let sanitized_args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let details = serde_json::json!({
"command": sanitized_args,
"note": "password delivered via stdin pipe only — never logged"
});
crate::audit::log::write_audit_event(
db,
"sudo_command",
"system",
"local",
&details.to_string(),
)
.map_err(|e| format!("Audit log failed: {e}"))?;
run_sudo_command(password, args)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_sudo_password_prompt_removes_prompt_lines() {
let stderr = "[sudo] password for alice:\nsome other output\nPassword: bad line";
let cleaned = strip_sudo_password_prompt(stderr);
assert!(!cleaned.contains("[sudo] password"));
assert!(!cleaned.contains("Password:"));
assert!(cleaned.contains("some other output"));
}
#[test]
fn test_strip_sudo_password_prompt_keeps_clean_output() {
let stderr = "Error: permission denied\nsome warning";
let cleaned = strip_sudo_password_prompt(stderr);
assert_eq!(cleaned, "Error: permission denied\nsome warning");
}
#[test]
fn test_run_sudo_command_audited_does_not_log_password() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let _result = run_sudo_command_audited("my-secret-password", &["true"], &conn);
// result may fail in test environment, but audit log must exist
let details: String = conn
.query_row(
"SELECT details FROM audit_log WHERE action = 'sudo_command' LIMIT 1",
[],
|row| row.get(0),
)
.unwrap_or_default();
assert!(
!details.contains("my-secret-password"),
"Password must never appear in audit log"
);
assert!(details.contains("true"), "Command args should be logged");
}
}

View File

@ -204,15 +204,19 @@ pub async fn chat_message(
} }
}; };
// Load conversation history (use and_then to keep stmt lifetime within closure) // Load conversation history across ALL conversations for this issue
let history: Vec<Message> = { let history: Vec<Message> = {
let db = state.db.lock().map_err(|e| e.to_string())?; let db = state.db.lock().map_err(|e| e.to_string())?;
let raw: Vec<(String, String)> = db let raw: Vec<(String, String)> = db
.prepare( .prepare(
"SELECT role, content FROM ai_messages WHERE conversation_id = ?1 ORDER BY created_at ASC", "SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
) )
.and_then(|mut stmt| { .and_then(|mut stmt| {
stmt.query_map([&conversation_id], |row| { stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
}) })
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>()) .map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
@ -1025,4 +1029,194 @@ mod tests {
let list = extract_list(text, "KEY_FINDINGS:"); let list = extract_list(text, "KEY_FINDINGS:");
assert_eq!(list, vec!["Actual item"]); assert_eq!(list, vec!["Actual item"]);
} }
#[test]
fn test_history_query_same_conversation() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "Hello", 5, "2025-01-01 10:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-1", "assistant", "Hi there", 8, "2025-01-01 10:00:01"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0], ("user".to_string(), "Hello".to_string()));
assert_eq!(raw[1], ("assistant".to_string(), "Hi there".to_string()));
}
#[test]
fn test_history_query_across_conversations() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "From conv 1", 5, "2025-01-01 10:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-2", "user", "From conv 2", 5, "2025-01-01 11:00:00"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0].1, "From conv 1");
assert_eq!(raw[1].1, "From conv 2");
}
#[test]
fn test_history_query_empty_for_new_issue() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-new", "Empty", &now, &now],
)
.unwrap();
let issue_id = "issue-new";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert!(raw.is_empty());
}
#[test]
fn test_history_query_ordered_by_created_at() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
)
.unwrap();
// Insert messages out of order: conv-2 message is earlier than conv-1 message
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "Second", 5, "2025-01-01 12:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-2", "user", "First", 5, "2025-01-01 09:00:00"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0].1, "First");
assert_eq!(raw[1].1, "Second");
}
} }

View File

@ -9,6 +9,141 @@ use crate::state::AppState;
const MAX_LOG_FILE_BYTES: u64 = 50 * 1024 * 1024; const MAX_LOG_FILE_BYTES: u64 = 50 * 1024 * 1024;
const SAFE_TEXT_EXTENSIONS: &[&str] = &[
"log",
"txt",
"out",
"err",
"syslog",
"journal",
"yaml",
"yml",
"json",
"toml",
"xml",
"ini",
"cfg",
"conf",
"config",
"env",
"properties",
"md",
"markdown",
"rst",
"csv",
"tsv",
"ndjson",
"jsonl",
"sql",
"sh",
"bash",
"zsh",
"py",
"js",
"ts",
"rb",
"go",
"rs",
"java",
"html",
"htm",
"css",
"diff",
"patch",
"rtf",
];
const SAFE_BINARY_EXTENSIONS: &[&str] = &["pdf", "docx", "doc", "xlsx", "xls"];
pub fn is_safe_file(path: &Path) -> bool {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
match ext.as_deref() {
Some(e) => SAFE_TEXT_EXTENSIONS.contains(&e) || SAFE_BINARY_EXTENSIONS.contains(&e),
None => false,
}
}
pub fn extract_text_content(path: &Path) -> Result<String, String> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
match ext.as_str() {
"pdf" => extract_pdf_text(path),
"docx" | "doc" => extract_docx_text(path),
"xlsx" | "xls" => Err(format!(
"Spreadsheet format .{ext} is not yet supported for text extraction. \
Export the sheet as CSV and upload that instead."
)),
_ => std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}")),
}
}
fn extract_pdf_text(path: &Path) -> Result<String, String> {
let doc = lopdf::Document::load(path).map_err(|e| format!("Failed to parse PDF: {e}"))?;
let mut text = String::new();
let mut pages: Vec<u32> = doc.get_pages().keys().copied().collect();
pages.sort_unstable();
for page_num in pages {
if let Ok(content) = doc.extract_text(&[page_num]) {
text.push_str(&content);
text.push('\n');
}
}
if text.trim().is_empty() {
return Err("PDF contains no extractable text (may be a scanned image)".to_string());
}
Ok(text)
}
fn extract_docx_text(path: &Path) -> Result<String, String> {
use std::io::Read as _;
let file = std::fs::File::open(path).map_err(|e| format!("Failed to open file: {e}"))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| format!("Failed to open as ZIP/DOCX: {e}"))?;
let mut xml_content = String::new();
{
// Safety: only one hardcoded entry is ever accessed; no arbitrary path extraction is
// performed, so zip-slip path traversal attacks cannot apply here.
let mut doc_xml = archive
.by_name("word/document.xml")
.map_err(|_| "Not a valid DOCX: missing word/document.xml".to_string())?;
doc_xml
.read_to_string(&mut xml_content)
.map_err(|e| format!("Failed to read document.xml: {e}"))?;
}
let mut text = String::new();
let mut reader = quick_xml::Reader::from_str(&xml_content);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(quick_xml::events::Event::Text(e)) => {
if let Ok(s) = e.unescape() {
let trimmed = s.trim().to_string();
if !trimmed.is_empty() {
text.push_str(&trimmed);
text.push(' ');
}
}
}
Ok(quick_xml::events::Event::Eof) => break,
Err(e) => return Err(format!("XML parse error: {e}")),
_ => {}
}
buf.clear();
}
if text.trim().is_empty() {
return Err("DOCX contains no extractable text".to_string());
}
Ok(text)
}
fn validate_log_file_path(file_path: &str) -> Result<PathBuf, String> { fn validate_log_file_path(file_path: &str) -> Result<PathBuf, String> {
let path = Path::new(file_path); let path = Path::new(file_path);
let canonical = std::fs::canonicalize(path).map_err(|_| "Unable to access selected file")?; let canonical = std::fs::canonicalize(path).map_err(|_| "Unable to access selected file")?;
@ -35,24 +170,59 @@ pub async fn upload_log_file(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<LogFile, String> { ) -> Result<LogFile, String> {
let canonical_path = validate_log_file_path(&file_path)?; let canonical_path = validate_log_file_path(&file_path)?;
let content = std::fs::read(&canonical_path).map_err(|_| "Failed to read selected log file")?;
let content_hash = format!("{:x}", Sha256::digest(&content)); if !is_safe_file(&canonical_path) {
let ext = canonical_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("(none)");
return Err(format!(
"File type '.{ext}' is not supported. Supported formats include .log, .txt, .json, .pdf, .docx, .md, and many more."
));
}
let file_name = canonical_path let file_name = canonical_path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
let file_size = content.len() as i64;
let mime_type = if file_name.ends_with(".json") { let file_ext = canonical_path
"application/json" .extension()
} else if file_name.ends_with(".xml") { .and_then(|e| e.to_str())
"application/xml" .map(|e| e.to_lowercase())
} else { .unwrap_or_default();
"text/plain"
let extracted_text = extract_text_content(&canonical_path)
.map_err(|e| format!("Failed to read file content: {e}"))?;
let content_bytes = extracted_text.as_bytes();
let content_hash = format!("{:x}", Sha256::digest(content_bytes));
let file_size = content_bytes.len() as i64;
let mime_type = match file_ext.as_str() {
"json" => "application/json",
"xml" => "application/xml",
"yaml" | "yml" => "application/yaml",
"pdf" => "application/pdf",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc" => "application/msword",
"md" | "markdown" => "text/markdown",
"csv" | "tsv" => "text/csv",
"html" | "htm" => "text/html",
_ => "text/plain",
}; };
let canonical_file_path = canonical_path.to_string_lossy().to_string(); let is_binary = SAFE_BINARY_EXTENSIONS.contains(&file_ext.as_str());
let log_file = LogFile::new(issue_id.clone(), file_name, canonical_file_path, file_size); let stored_path = if is_binary {
let extracted_path = canonical_path.with_extension("extracted.txt");
std::fs::write(&extracted_path, &extracted_text)
.map_err(|e| format!("Failed to write extracted text: {e}"))?;
extracted_path.to_string_lossy().to_string()
} else {
canonical_path.to_string_lossy().to_string()
};
let log_file = LogFile::new(issue_id.clone(), file_name, stored_path, file_size);
let log_file = LogFile { let log_file = LogFile {
content_hash: content_hash.clone(), content_hash: content_hash.clone(),
mime_type: mime_type.to_string(), mime_type: mime_type.to_string(),
@ -104,17 +274,36 @@ pub async fn upload_log_file_by_content(
content: String, content: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<LogFile, String> { ) -> Result<LogFile, String> {
let fake_path = Path::new(&file_name);
if !is_safe_file(fake_path) {
let ext = fake_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("(none)");
return Err(format!("File type '.{ext}' is not supported."));
}
let content_bytes = content.as_bytes(); let content_bytes = content.as_bytes();
let content_hash = format!("{:x}", Sha256::digest(content_bytes)); let content_hash = format!("{:x}", Sha256::digest(content_bytes));
let file_size = content_bytes.len() as i64; let file_size = content_bytes.len() as i64;
// Determine mime type based on file extension let file_ext = fake_path
let mime_type = if file_name.ends_with(".json") { .extension()
"application/json" .and_then(|e| e.to_str())
} else if file_name.ends_with(".xml") { .map(|e| e.to_lowercase())
"application/xml" .unwrap_or_default();
} else {
"text/plain" let mime_type = match file_ext.as_str() {
"json" => "application/json",
"xml" => "application/xml",
"yaml" | "yml" => "application/yaml",
"pdf" => "application/pdf",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc" => "application/msword",
"md" | "markdown" => "text/markdown",
"csv" | "tsv" => "text/csv",
"html" | "htm" => "text/html",
_ => "text/plain",
}; };
// Use the file_name as the file_path for DB storage // Use the file_name as the file_path for DB storage
@ -328,4 +517,68 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
let _ = std::fs::remove_file(file_path); let _ = std::fs::remove_file(file_path);
} }
#[test]
fn test_is_safe_file_allows_txt() {
assert!(is_safe_file(Path::new("file.txt")));
}
#[test]
fn test_is_safe_file_allows_md() {
assert!(is_safe_file(Path::new("readme.md")));
}
#[test]
fn test_is_safe_file_allows_pdf() {
assert!(is_safe_file(Path::new("report.pdf")));
}
#[test]
fn test_is_safe_file_allows_docx() {
assert!(is_safe_file(Path::new("doc.docx")));
}
#[test]
fn test_is_safe_file_rejects_exe() {
assert!(!is_safe_file(Path::new("malware.exe")));
}
#[test]
fn test_is_safe_file_rejects_dll() {
assert!(!is_safe_file(Path::new("library.dll")));
}
#[test]
fn test_is_safe_file_rejects_zip_directly() {
assert!(!is_safe_file(Path::new("archive.zip")));
}
#[test]
fn test_is_safe_file_case_insensitive() {
assert!(is_safe_file(Path::new("file.TXT")));
assert!(is_safe_file(Path::new("file.Log")));
}
#[test]
fn test_is_safe_file_no_extension_rejected() {
assert!(!is_safe_file(Path::new("Makefile")));
}
#[test]
fn test_extract_text_plain_file() {
let dir = std::env::temp_dir();
let path = dir.join(format!("tftsr-test-extract-{}.txt", uuid::Uuid::now_v7()));
std::fs::write(&path, "hello world").unwrap();
let result = extract_text_content(&path);
assert!(result.is_ok());
assert_eq!(result.unwrap().trim(), "hello world");
let _ = std::fs::remove_file(path);
}
#[test]
fn test_extract_text_unsupported_binary_returns_error() {
let result = extract_text_content(Path::new("data.xlsx"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("not yet supported"));
}
} }

View File

@ -347,7 +347,7 @@ pub async fn list_issues(
let offset = filter.offset.unwrap_or(0); let offset = filter.offset.unwrap_or(0);
let mut sql = String::from( let mut sql = String::from(
"SELECT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \ "SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
(SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \ (SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \
(SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \ (SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \
FROM issues i WHERE 1=1", FROM issues i WHERE 1=1",
@ -384,9 +384,19 @@ pub async fn list_issues(
} }
if let Some(ref search) = filter.search { if let Some(ref search) = filter.search {
let pattern = format!("%{search}%"); let pattern = format!("%{search}%");
let idx = params.len() + 1;
sql.push_str(&format!( sql.push_str(&format!(
" AND (i.title LIKE ?{0} OR i.description LIKE ?{0} OR i.category LIKE ?{0})", " AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
params.len() + 1 OR EXISTS (SELECT 1 FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM resolution_steps rs \
WHERE rs.issue_id = i.id \
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
OR EXISTS (SELECT 1 FROM log_files lf \
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM timeline_events te \
WHERE te.issue_id = i.id AND te.description LIKE ?{idx}))",
)); ));
params.push(Box::new(pattern)); params.push(Box::new(pattern));
} }
@ -635,3 +645,163 @@ pub async fn get_timeline_events(
.collect(); .collect();
Ok(events) Ok(events)
} }
#[cfg(test)]
mod tests {
use rusqlite::Connection;
fn setup_test_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
conn
}
fn insert_issue(conn: &Connection, id: &str, title: &str, description: &str) {
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, assigned_to, tags) \
VALUES (?1, ?2, ?3, 'medium', 'open', 'general', 'manual', ?4, ?5, '', '[]')",
rusqlite::params![id, title, description, &now, &now],
)
.unwrap();
}
fn run_search_query(conn: &Connection, search: &str) -> Vec<String> {
let pattern = format!("%{search}%");
let idx = 1;
let sql = format!(
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
(SELECT COUNT(*) FROM log_files lf2 WHERE lf2.issue_id = i.id) as log_count, \
(SELECT COUNT(*) FROM resolution_steps rs2 WHERE rs2.issue_id = i.id) as step_count \
FROM issues i WHERE 1=1 \
AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
OR EXISTS (SELECT 1 FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM resolution_steps rs \
WHERE rs.issue_id = i.id \
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
OR EXISTS (SELECT 1 FROM log_files lf \
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM timeline_events te \
WHERE te.issue_id = i.id AND te.description LIKE ?{idx})) \
ORDER BY i.updated_at DESC"
);
let mut stmt = conn.prepare(&sql).unwrap();
stmt.query_map([&pattern], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
#[test]
fn test_search_finds_by_title() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Kubernetes OOM crash", "No details");
insert_issue(&conn, "issue-2", "Network timeout", "No details");
let results = run_search_query(&conn, "Kubernetes");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_description() {
let conn = setup_test_db();
insert_issue(
&conn,
"issue-1",
"Generic title",
"The pod was killed due to memory pressure",
);
insert_issue(&conn, "issue-2", "Other", "All fine");
let results = run_search_query(&conn, "memory pressure");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_ai_message_content() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
).unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "assistant", "The root cause is a deadlock in PostgreSQL", 10, &now],
).unwrap();
let results = run_search_query(&conn, "deadlock in PostgreSQL");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_resolution_step_answer() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO resolution_steps (id, issue_id, step_order, why_question, answer, evidence, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params!["step-1", "issue-1", 1, "Why did it fail?", "Connection pool exhausted", "metrics dashboard", &now],
).unwrap();
let results = run_search_query(&conn, "pool exhausted");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_log_file_name() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params!["lf-1", "issue-1", "nginx-error-2025.log", "/tmp/nginx-error-2025.log", 1024, "text/plain", "abc", &now, 0],
).unwrap();
let results = run_search_query(&conn, "nginx-error");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_timeline_description() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
conn.execute(
"INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["te-1", "issue-1", "triage_started", "Engineer started investigating DNS resolution failure", "{}", "2025-01-15 10:00:00"],
).unwrap();
let results = run_search_query(&conn, "DNS resolution");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_no_duplicates_for_multiple_matches() {
let conn = setup_test_db();
insert_issue(
&conn,
"issue-1",
"Kubernetes crash",
"Kubernetes pod killed",
);
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
).unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "assistant", "Kubernetes OOM detected", 5, &now],
).unwrap();
let results = run_search_query(&conn, "Kubernetes");
assert_eq!(results.len(), 1);
assert_eq!(results[0], "issue-1");
}
}

View File

@ -1,3 +1,4 @@
pub mod agentic;
pub mod ai; pub mod ai;
pub mod analysis; pub mod analysis;
pub mod db; pub mod db;

View File

@ -284,3 +284,178 @@ pub async fn get_app_version() -> Result<String, String> {
.or_else(|_| env::var("CARGO_PKG_VERSION")) .or_else(|_| env::var("CARGO_PKG_VERSION"))
.map_err(|e| format!("Failed to get version: {e}")) .map_err(|e| format!("Failed to get version: {e}"))
} }
// --- Sudo credential commands ---
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct SudoConfigStatus {
pub configured: bool,
pub username: String,
pub updated_at: String,
}
/// Resolve the OS username to bind sudo credentials to.
fn resolve_sudo_username(provided: Option<String>) -> String {
provided
.filter(|u| !u.trim().is_empty())
.unwrap_or_else(|| {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "local".to_string())
})
}
#[tauri::command]
pub async fn set_sudo_password(
password: String,
username: Option<String>,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
let encrypted = crate::integrations::auth::encrypt_token(&password)?;
let uname = resolve_sudo_username(username);
let db = state.db.lock().map_err(|e| e.to_string())?;
// DELETE then INSERT to guarantee exactly one row at all times.
// INSERT OR REPLACE with a freshly generated UUID never matches the
// existing primary key, so it inserts an additional row instead of
// replacing — this is the correct singleton pattern for SQLite.
db.execute("DELETE FROM sudo_config", [])
.map_err(|e| format!("Failed to clear sudo config: {e}"))?;
db.execute(
"INSERT INTO sudo_config (id, username, encrypted_password, created_at, updated_at) \
VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))",
rusqlite::params![uuid::Uuid::now_v7().to_string(), uname, encrypted],
)
.map_err(|e| format!("Failed to store sudo config: {e}"))?;
Ok(())
}
#[tauri::command]
pub async fn get_sudo_config_status(
state: tauri::State<'_, AppState>,
) -> Result<SudoConfigStatus, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let result: Option<(String, String)> = db
.prepare("SELECT username, updated_at FROM sudo_config LIMIT 1")
.and_then(|mut stmt| {
stmt.query_row([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
})
.ok();
match result {
Some((username, updated_at)) => Ok(SudoConfigStatus {
configured: true,
username,
updated_at,
}),
None => Ok(SudoConfigStatus {
configured: false,
username: String::new(),
updated_at: String::new(),
}),
}
}
#[tauri::command]
pub async fn test_sudo_password(state: tauri::State<'_, AppState>) -> Result<bool, String> {
let (encrypted, stored_username) = {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.prepare("SELECT encrypted_password, username FROM sudo_config LIMIT 1")
.and_then(|mut stmt| {
stmt.query_row([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
})
.ok()
.ok_or("No sudo password configured".to_string())?
};
let password = crate::integrations::auth::decrypt_token(&encrypted)?;
// Scope the test to the stored username so credentials can only be
// verified for the user they were saved for.
let result = if stored_username.is_empty() {
crate::commands::agentic::run_sudo_command(&password, &["true"])
} else {
crate::commands::agentic::run_sudo_command(&password, &["-u", &stored_username, "true"])
}
.map_err(|e| format!("Sudo test failed: {e}"))?;
Ok(result.success)
}
#[tauri::command]
pub async fn clear_sudo_password(state: tauri::State<'_, AppState>) -> Result<(), String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute("DELETE FROM sudo_config", [])
.map_err(|e| format!("Failed to clear sudo config: {e}"))?;
Ok(())
}
#[cfg(test)]
mod sudo_tests {
use super::*;
fn setup_db() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
conn
}
#[test]
fn test_set_sudo_singleton_delete_then_insert() {
let conn = setup_db();
// Insert two stale rows directly to simulate the old broken behaviour
conn.execute(
"INSERT INTO sudo_config (id, username, encrypted_password) VALUES ('id1', 'alice', 'enc1')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO sudo_config (id, username, encrypted_password) VALUES ('id2', 'bob', 'enc2')",
[],
)
.unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sudo_config", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 2);
// Apply the correct singleton pattern
conn.execute("DELETE FROM sudo_config", []).unwrap();
conn.execute(
"INSERT INTO sudo_config (id, username, encrypted_password) VALUES ('id3', 'charlie', 'enc3')",
[],
)
.unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sudo_config", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1, "exactly one row must remain after set");
let username: String = conn
.query_row("SELECT username FROM sudo_config", [], |r| r.get(0))
.unwrap();
assert_eq!(username, "charlie");
}
#[test]
fn test_resolve_sudo_username_uses_provided() {
let result = resolve_sudo_username(Some("alice".to_string()));
assert_eq!(result, "alice");
}
#[test]
fn test_resolve_sudo_username_rejects_blank() {
let result = resolve_sudo_username(Some(" ".to_string()));
// blank string should fall through to env-based default
assert!(!result.trim().is_empty(), "username must never be blank");
}
#[test]
fn test_resolve_sudo_username_defaults_to_env() {
let env_user = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "local".to_string());
let result = resolve_sudo_username(None);
assert_eq!(result, env_user);
}
}

View File

@ -249,6 +249,16 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
FOREIGN KEY(server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE FOREIGN KEY(server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE
);", );",
), ),
(
"019_create_sudo_config",
"CREATE TABLE IF NOT EXISTS sudo_config (
id TEXT PRIMARY KEY,
username TEXT NOT NULL DEFAULT '',
encrypted_password TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
),
]; ];
for (name, sql) in migrations { for (name, sql) in migrations {
@ -1034,4 +1044,61 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(applied, 1, "018 should only be recorded once"); assert_eq!(applied, 1, "018 should only be recorded once");
} }
// ─── Migration 019: sudo_config ─────────────────────────────────────────────
#[test]
fn test_019_sudo_config_table_exists() {
let conn = setup_test_db();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sudo_config'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_019_sudo_config_columns() {
let conn = setup_test_db();
let mut stmt = conn.prepare("PRAGMA table_info(sudo_config)").unwrap();
let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(columns.contains(&"id".to_string()));
assert!(columns.contains(&"username".to_string()));
assert!(columns.contains(&"encrypted_password".to_string()));
assert!(columns.contains(&"created_at".to_string()));
assert!(columns.contains(&"updated_at".to_string()));
}
#[test]
fn test_019_idempotent() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sudo_config'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1, "sudo_config table should exist after double-run");
let applied: i64 = conn
.query_row(
"SELECT COUNT(*) FROM _migrations WHERE name = '019_create_sudo_config'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(applied, 1, "019 should only be recorded once");
}
} }

View File

@ -133,6 +133,10 @@ pub fn run() {
commands::system::update_settings, commands::system::update_settings,
commands::system::get_audit_log, commands::system::get_audit_log,
commands::system::get_app_version, commands::system::get_app_version,
commands::system::set_sudo_password,
commands::system::get_sudo_config_status,
commands::system::test_sudo_password,
commands::system::clear_sudo_password,
// MCP Servers // MCP Servers
mcp::commands::list_mcp_servers, mcp::commands::list_mcp_servers,
mcp::commands::create_mcp_server, mcp::commands::create_mcp_server,

View File

@ -591,6 +591,26 @@ export function initiateMcpOauthCmd(id: string): Promise<void> {
return invoke<void>("initiate_mcp_oauth", { id }); return invoke<void>("initiate_mcp_oauth", { id });
} }
// ─── Sudo credential commands ─────────────────────────────────────────────────
export interface SudoConfigStatus {
configured: boolean;
username: string;
updated_at: string;
}
export const setSudoPasswordCmd = (password: string, username?: string) =>
invoke<void>("set_sudo_password", { password, username: username ?? null });
export const getSudoConfigStatusCmd = () =>
invoke<SudoConfigStatus>("get_sudo_config_status");
export const testSudoPasswordCmd = () =>
invoke<boolean>("test_sudo_password");
export const clearSudoPasswordCmd = () =>
invoke<void>("clear_sudo_password");
// ─── System / Version ───────────────────────────────────────────────────────── // ─── System / Version ─────────────────────────────────────────────────────────
export const getAppVersionCmd = () => export const getAppVersionCmd = () =>

View File

@ -252,8 +252,21 @@ export default function LogUpload() {
multiple multiple
className="hidden" className="hidden"
onChange={handleFileSelect} onChange={handleFileSelect}
accept=".log,.txt,.json,.csv,.xml,.yaml,.yml" accept=".log,.txt,.out,.err,.syslog,.journal,.yaml,.yml,.json,.toml,.xml,.ini,.cfg,.conf,.config,.env,.properties,.md,.markdown,.rst,.csv,.tsv,.ndjson,.jsonl,.sql,.sh,.bash,.zsh,.py,.js,.ts,.rb,.go,.rs,.java,.html,.htm,.css,.diff,.patch,.pdf,.docx,.doc,.rtf,.xlsx,.xls"
/> />
<details className="mt-2 text-sm text-gray-500 dark:text-gray-400">
<summary className="cursor-pointer hover:text-gray-700 dark:hover:text-gray-200">
Supported formats
</summary>
<div className="mt-1 pl-3 space-y-1">
<div><span className="font-medium">Logs &amp; text:</span> .log, .txt, .out, .err, .syslog, .journal</div>
<div><span className="font-medium">Config &amp; markup:</span> .yaml, .yml, .json, .toml, .xml, .ini, .cfg, .conf, .env, .properties</div>
<div><span className="font-medium">Documents:</span> .pdf, .docx, .doc, .md, .rst, .rtf</div>
<div><span className="font-medium">Data:</span> .csv, .tsv, .xlsx, .xls, .ndjson, .jsonl, .sql</div>
<div><span className="font-medium">Code &amp; scripts:</span> .sh, .bash, .zsh, .py, .js, .ts, .rb, .go, .rs, .java, .html, .css, .diff, .patch</div>
<p className="mt-1 italic">Binary formats (PDF, DOCX, XLSX) will have their text extracted automatically.</p>
</div>
</details>
</div> </div>
{/* File list */} {/* File list */}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Shield, RefreshCw } from "lucide-react"; import { Shield, RefreshCw, Lock } from "lucide-react";
import { import {
Card, Card,
CardHeader, CardHeader,
@ -7,7 +7,15 @@ import {
CardContent, CardContent,
Badge, Badge,
} from "@/components/ui"; } from "@/components/ui";
import { getAuditLogCmd, type AuditEntry } from "@/lib/tauriCommands"; import {
getAuditLogCmd,
getSudoConfigStatusCmd,
setSudoPasswordCmd,
testSudoPasswordCmd,
clearSudoPasswordCmd,
type AuditEntry,
type SudoConfigStatus,
} from "@/lib/tauriCommands";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
const piiPatterns = [ const piiPatterns = [
@ -28,8 +36,15 @@ export default function Security() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sudoPassword, setSudoPassword] = useState("");
const [sudoUsername, setSudoUsername] = useState("");
const [sudoStatus, setSudoStatus] = useState<SudoConfigStatus | null>(null);
const [sudoMessage, setSudoMessage] = useState("");
const [sudoTesting, setSudoTesting] = useState(false);
useEffect(() => { useEffect(() => {
loadAuditLog(); loadAuditLog();
loadSudoStatus();
}, []); }, []);
const loadAuditLog = async () => { const loadAuditLog = async () => {
@ -44,6 +59,51 @@ export default function Security() {
} }
}; };
const loadSudoStatus = async () => {
try {
const status = await getSudoConfigStatusCmd();
setSudoStatus(status);
} catch {
// ignore — table may not exist yet
}
};
const handleSaveSudo = async () => {
setSudoMessage("");
try {
await setSudoPasswordCmd(sudoPassword, sudoUsername || undefined);
setSudoPassword("");
setSudoMessage("Saved successfully");
await loadSudoStatus();
} catch (err) {
setSudoMessage(`Error: ${String(err)}`);
}
};
const handleTestSudo = async () => {
setSudoTesting(true);
setSudoMessage("");
try {
const ok = await testSudoPasswordCmd();
setSudoMessage(ok ? "Password verified" : "Authentication failed");
} catch (err) {
setSudoMessage(`Authentication failed: ${String(err)}`);
} finally {
setSudoTesting(false);
}
};
const handleClearSudo = async () => {
setSudoMessage("");
try {
await clearSudoPasswordCmd();
setSudoMessage("Credentials cleared");
await loadSudoStatus();
} catch (err) {
setSudoMessage(`Error: ${String(err)}`);
}
};
const toggleRow = (entryId: string) => { const toggleRow = (entryId: string) => {
setExpandedRows((prev) => { setExpandedRows((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
@ -103,6 +163,86 @@ export default function Security() {
</CardContent> </CardContent>
</Card> </Card>
{/* Sudo Credentials */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Lock className="w-5 h-5" />
Sudo Credentials
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{sudoStatus?.configured && (
<div className="text-sm text-green-600 space-y-0.5">
<p>Configured for <strong>{sudoStatus.username}</strong></p>
<p className="text-xs text-muted-foreground">Last updated: {sudoStatus.updated_at}</p>
</div>
)}
{sudoStatus && !sudoStatus.configured && (
<p className="text-sm text-muted-foreground">Not configured</p>
)}
<div className="space-y-3">
<div>
<label className="text-sm font-medium" htmlFor="sudo-username">
Username
</label>
<input
id="sudo-username"
type="text"
value={sudoUsername}
onChange={(e) => setSudoUsername(e.target.value)}
placeholder="Defaults to current OS user"
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Credentials are scoped to this user. Leave blank to use the current OS user.
</p>
</div>
<div>
<label className="text-sm font-medium" htmlFor="sudo-password">
Password
</label>
<input
id="sudo-password"
type="password"
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
placeholder="Enter sudo password"
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveSudo}
disabled={!sudoPassword}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Save
</button>
<button
onClick={handleTestSudo}
disabled={sudoTesting || !sudoStatus?.configured}
className="px-3 py-1.5 text-sm rounded-md border border-input hover:bg-accent disabled:opacity-50"
>
{sudoTesting ? "Testing..." : "Test"}
</button>
<button
onClick={handleClearSudo}
disabled={!sudoStatus?.configured}
className="px-3 py-1.5 text-sm rounded-md border border-destructive text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
Clear
</button>
</div>
{sudoMessage && (
<p className={`text-sm ${sudoMessage.startsWith("Error") || sudoMessage.includes("failed") ? "text-destructive" : "text-green-600"}`}>
{sudoMessage}
</p>
)}
</CardContent>
</Card>
{/* Audit Log */} {/* Audit Log */}
<Card> <Card>
<CardHeader> <CardHeader>