Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "uphold"
version = "1.1.0"
version = "1.1.1"
edition = "2021"
# 1.88, not 1.85: the ripgrep stack this crate embeds -- globset 0.4.20 and
# ignore 0.4.33 in Cargo.lock -- refuses anything older. The 1.85 that stood here
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ bootstraps).
default_install_hook_types: [pre-commit, commit-msg, pre-merge-commit, pre-push]
repos:
- repo: https://github.com/HackingGate/uphold
rev: v1.1.0
rev: v1.1.1
hooks:
- id: uphold-check # the claims still hold
- id: uphold-scan # the content policy
Expand All @@ -48,13 +48,13 @@ the binary must be on PATH.
# lefthook.yml
remotes:
- git_url: https://github.com/HackingGate/uphold
ref: v1.1.0
ref: v1.1.1
configs:
- hooks/lefthook.yml
```

```sh
cargo install --git https://github.com/HackingGate/uphold --tag v1.1.0
cargo install --git https://github.com/HackingGate/uphold --tag v1.1.1
```

## Declare what enforces what
Expand Down
4 changes: 2 additions & 2 deletions hooks/lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# # lefthook.yml, in the consuming repository
# remotes:
# - git_url: https://github.com/HackingGate/uphold
# ref: v1.1.0
# ref: v1.1.1
# configs:
# - hooks/lefthook.yml
#
Expand All @@ -21,7 +21,7 @@
# their manifest contract; lefthook runs commands and has no such contract, so
# `uphold` must be on PATH:
#
# cargo install --git https://github.com/HackingGate/uphold --tag v1.1.0
# cargo install --git https://github.com/HackingGate/uphold --tag v1.1.1
#
# The names below are prefixed `uphold-` on purpose. A remote config is
# merged into the consumer's own, and two commands sharing a name under one hook
Expand Down
42 changes: 41 additions & 1 deletion src/guard/unicode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ use globset::{Glob, GlobMatcher};
use super::scope;
use super::{Refusal, Request};
use crate::error::{Fatal, Result};
use crate::selection::{normalize_rel, not_text_paths};

/// A codepoint admitted, optionally only under one path glob.
struct Allowance {
Expand Down Expand Up @@ -273,6 +274,24 @@ pub(crate) fn in_files(request: &Request<'_>) -> Result<Option<Refusal>> {
request.push_source,
request.remote_name,
)?;
// The same declaration `uphold scan` reads, from the same place, because a
// file the repository declares is not text is one file with one answer and
// not two. It said so at the tree seam and refused at this one: a captured
// page kept byte-for-byte in the encoding its venue served -- the use
// `not_text_paths` names -- was skipped by the scan and made every commit
// touching it exit 2 here, and `.gitattributes` is one of the three cures
// the reference names for exactly that.
//
// The NUL test below keeps its job, which is a different one: it is the
// guess about bytes NOBODY declared. A declaration is not a guess, and
// where there is one it answers first. A `.gitattributes` that could not be
// read leaves the list empty and the reason set, and then the refusal
// stands with that reason attached -- an unanswered question is not a
// declaration that a file is fine to skip.
let (not_text, unmeasured) = not_text_paths(request.root);
let declared_not_text: BTreeSet<&str> =
not_text.iter().map(|path| normalize_rel(path)).collect();
Comment on lines +291 to +293

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the declaration-selection module before inspecting the implementation.
ast-grep outline src/selection.rs --items all

# Inspect whether declaration lookup uses Git's index, for example via
# `git check-attr --cached`, rather than the working tree.
rg -n -C 10 'fn not_text_paths\b|not_text_paths\(|check-attr|--cached|gitattributes' \
  src/selection.rs src/guard/unicode.rs

# Inspect the current staged-declaration test before adding the unstaged case.
sed -n '238,280p' tests/guard_cli.rs

Repository: HackingGate/uphold

Length of output: 21277


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- selection index and attribute implementation ---'
sed -n '50,180p' src/selection.rs

printf '%s\n' '--- staged blob collection and guard request construction ---'
rg -n -C 12 'fn blobs\b|blobs\(|struct Request|stage|index_bytes|from_index' src/guard src/selection.rs

printf '%s\n' '--- relevant guard tests and helpers ---'
rg -n -C 8 'a_path_declared_not_text|fn guard\b|--stage|pre-commit|captured.html' tests/guard_cli.rs tests

printf '%s\n' '--- local Git attribute semantics, without changing the repository ---'
git check-attr text -- .gitattributes 2>&1 || true
git check-attr --cached text -- .gitattributes 2>&1 || true

printf '%s\n' '--- source-level behavioral verifier ---'
python3 - <<'PY'
from pathlib import Path

selection = Path("src/selection.rs").read_text()
unicode = Path("src/guard/unicode.rs").read_text()

start = selection.index("pub(crate) fn not_text_paths")
end = selection.index("\npub(crate) struct Selection", start)
impl_text = selection[start:end]

assert 'args(["check-attr", "--stdin", "-z", "text"])' in impl_text
assert "--cached" not in impl_text
assert "current_dir(root)" in impl_text

blobs_call = unicode.index("let blobs =")
blobs_end = unicode.index(";", blobs_call)
request_call = unicode.index("not_text_paths(request.root)")
print("not_text_paths uses working-tree `git check-attr` without `--cached`.")
print("The guard resolves attributes from `request.root` independently of its staged blob collection.")
print("Conclusion:", request_call > blobs_end)
PY

Repository: HackingGate/uphold

Length of output: 50375


Resolve .gitattributes against the scanned tree.

not_text_paths uses working-tree attributes, while scope::blobs scans staged or pushed objects. An unstaged .gitattributes change can suppress scanning of a staged blob. Use the same staged or committed tree for both decisions, and add regression coverage.

📍 Affects 2 files
  • src/guard/unicode.rs#L291-L293 (this comment)
  • tests/guard_cli.rs#L265-L279
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/guard/unicode.rs` around lines 291 - 293, Update src/guard/unicode.rs at
lines 291-293 so not_text_paths resolves .gitattributes from the same staged or
committed tree used by scope::blobs, rather than the working tree; preserve path
normalization and declared_not_text construction. Add regression coverage in
tests/guard_cli.rs at lines 265-279 that reproduces an unstaged .gitattributes
change suppressing scanning of a staged blob and verifies the blob is still
scanned.

Source: Coding guidelines

let mut skipped: Vec<&str> = Vec::new();
let mut findings: Vec<String> = Vec::new();
let mut looked = 0usize;

Expand All @@ -294,6 +313,12 @@ pub(crate) fn in_files(request: &Request<'_>) -> Result<Option<Refusal>> {
if !blob.has_content() {
continue;
}
// After the name and before the bytes. The name is committed text
// whatever the content is declared to be.
if let Some(path) = declared_not_text.get(normalize_rel(&blob.path)) {
skipped.push(path);
continue;
}
let bytes = scope::read(request.root, blob)?;
match decode_for_scan(&bytes) {
Decoded::Text(text) => {
Expand All @@ -307,15 +332,30 @@ pub(crate) fn in_files(request: &Request<'_>) -> Result<Option<Refusal>> {
// reported as a file with nothing in it -- `explicit-unknown` by
// name, in the guard that reports it about everyone else.
Decoded::Unreadable(why) => {
let unknown = unmeasured.as_deref().unwrap_or(
"Declare it not text in .gitattributes, declare its charset with an \
`encoding` rule, or exclude it from this rule.",
);
return Err(Fatal::new(format!(
"{}: cannot be read as text ({why}); refusing to report it clean \
over content that was never examined",
over content that was never examined. {unknown}",
blob.path
)));
}
}
}

// Said on the way past, refusal or not, for the reason `not_text_paths`
// gives about its own two answers: "we did not check these" and "these were
// clean" must never look the same on the way out.
if !skipped.is_empty() {
eprintln!(
"{}: {} path(s) skipped, declared not text in .gitattributes:\n{}",
request.rule.id,
skipped.len(),
skipped.join("\n")
);
}
if findings.is_empty() {
return Ok(None);
}
Expand Down
53 changes: 52 additions & 1 deletion tests/guard_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,19 @@ fn repository(policy: &str) -> PathBuf {
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("policy")).unwrap();
std::fs::write(root.join("policy/principles.toml"), policy).unwrap();

// Git first, policy second, and the order is load-bearing on any machine
// where this binary is installed in front of `git`. Several fixtures here
// carry a policy that is MEANT to be refused at load, and the shim reads
// the policy of the directory the command was typed in before it knows
// whether anything stands in front of that command -- so writing the file
// first made `git init` exit 2 inside the fixture, and the failure looked
// like the test's own subject rather than like its setup.
git(&root, &["init", "-q", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "test@example.test"]);

std::fs::write(root.join("policy/principles.toml"), policy).unwrap();
root
}

Expand Down Expand Up @@ -227,6 +235,49 @@ fn an_allowance_scoped_to_a_path_admits_the_character_only_there() {
);
}

/// One file, one answer, at both seams.
///
/// A captured page kept byte-for-byte in the encoding its venue served is the
/// use `.gitattributes` `-text` exists for, and `uphold scan` skips one and says
/// so. This guard read the same file, failed to decode it, and refused -- so a
/// repository following the documented cure got a clean tree from one seam and
/// exit 2 from the other on every commit that touched the file.
///
/// The NUL test keeps its own job below: it is the guess about bytes nobody
/// declared, and a declaration is not a guess.
#[test]
fn a_path_declared_not_text_is_skipped_by_the_guard_that_cannot_decode_it() {
let root =
repository("[rule.prevent-unusual-unicode-in-files]\nbuiltin = \"prevent-unusual-unicode-in-files\"\n\n[rule.prevent-unusual-unicode-in-files.git]\nhooks = [\"pre-commit\"]\n");
// Shift-JIS bytes: valid text in their own encoding, not UTF-8, and no NUL
// to make the binary guess fire.
std::fs::write(root.join("captured.html"), [0x93, 0xFA, 0x96, 0x7B, 0x0A]).unwrap();
git(&root, &["add", "-A"]);

let output = guard(&root, &["--stage", "pre-commit"]);
assert_eq!(
code(&output),
2,
"undeclared bytes that will not decode are still a surface nobody read:\n{}",
stderr(&output)
);

write(&root, ".gitattributes", "captured.html -text\n");
git(&root, &["add", "-A"]);
let output = guard(&root, &["--stage", "pre-commit"]);
assert_eq!(code(&output), 0, "{}", stderr(&output));
assert!(
stderr(&output).contains("skipped, declared not text"),
"a skipped file and a clean file must not look alike:\n{}",
stderr(&output)
);
assert!(
stderr(&output).contains("captured.html"),
"{}",
stderr(&output)
);
}

#[test]
fn a_symlinks_blob_is_its_target_path() {
// Git stores the TARGET PATH as the blob, so a link whose target carries a
Expand Down
Loading