chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:47 +08:00
commit c4f19ccbbb
87 changed files with 34800 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
name: release
on:
release:
types: [ published ]
workflow_dispatch:
inputs:
tag:
required: true
type: string
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: ${{ matrix.artifact }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# ----- Linux (glibc) -----
- artifact: fselect-x86_64-linux
target: x86_64-unknown-linux-gnu
os: ubuntu-latest
cross: true
fmt: gz
- artifact: fselect-aarch64-linux
target: aarch64-unknown-linux-gnu
os: ubuntu-latest
cross: true
fmt: gz
# ----- Linux (musl, fully static) -----
- artifact: fselect-x86_64-linux-musl
target: x86_64-unknown-linux-musl
os: ubuntu-latest
cross: true
fmt: gz
- artifact: fselect-aarch64-linux-musl
target: aarch64-unknown-linux-musl
os: ubuntu-latest
cross: true
fmt: gz
# ----- Windows -----
- artifact: fselect-x86_64-win
target: x86_64-pc-windows-msvc
os: windows-latest
cross: false
fmt: zip
# ----- macOS -----
- artifact: fselect-x86_64-mac
target: x86_64-apple-darwin
os: macos-13 # Intel runner
cross: false
fmt: gz
- artifact: fselect-aarch64-mac
target: aarch64-apple-darwin
os: macos-latest # Apple Silicon runner
cross: false
fmt: gz
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross
if: matrix.cross
uses: taiki-e/install-action@v2
with:
tool: cross
- name: Build
shell: bash
run: |
if [ "${{ matrix.cross }}" = "true" ]; then
cross build --release --locked --features git,git2/vendored-libgit2 --target ${{ matrix.target }}
else
cargo build --release --locked --features git,git2/vendored-libgit2 --target ${{ matrix.target }}
fi
- name: Package
shell: bash
run: |
if [ "${{ matrix.fmt }}" = "zip" ]; then
7z a "${{ matrix.artifact }}.zip" "./target/${{ matrix.target }}/release/fselect.exe"
else
gzip -c "./target/${{ matrix.target }}/release/fselect" > "${{ matrix.artifact }}.gz"
fi
- name: Upload to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
files: ${{ matrix.artifact }}.${{ matrix.fmt }}
deb-package:
name: deb (x86_64)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deb
uses: taiki-e/install-action@v2
with:
tool: cargo-deb
- name: Build .deb package
run: cargo deb --features git,git2/vendored-libgit2 --output target/debian/
- name: Upload to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
files: target/debian/*.deb
# Fedora/RHEL/openSUSE .rpm package (x86_64): fselect-<version>-1.x86_64.rpm
rpm-package:
name: rpm (x86_64)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-generate-rpm
run: cargo install cargo-generate-rpm --locked
- name: Build release binary
run: cargo build --release --locked --features git,git2/vendored-libgit2
- name: Strip binary
run: strip -s target/release/fselect
- name: Build .rpm package
run: cargo generate-rpm
- name: Upload to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
files: target/generate-rpm/*.rpm
+56
View File
@@ -0,0 +1,56 @@
name: build
on:
push:
branches: [ master ]
paths:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
pull_request:
branches: [ master ]
paths:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
os: ['ubuntu-latest', 'windows-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/cache@v5
id: cache-deps
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Build test image and run tests in Docker
if: matrix.os == 'ubuntu-latest'
run: |
cargo build --release
mkdir docker-test && cp target/release/fselect docker-test/
cat > docker-test/Dockerfile <<EOF
FROM jhspetersson/fselect-tests
COPY fselect /opt/
ENTRYPOINT ["/opt/run_tests.sh"]
EOF
docker build -t fselect-test-img docker-test
docker run --rm fselect-test-img
shell: bash
+22
View File
@@ -0,0 +1,22 @@
# OSX trash
.DS_Store
# Eclipse files
.classpath
.project
.settings/**
# Vim swap files
*.swp
# Files generated by JetBrains IDEs, e.g. IntelliJ IDEA
.idea/
*.iml
out/
# Vscode files
.vscode/**
target
.cargo/**
+1
View File
@@ -0,0 +1 @@
fselect.rocks
Generated
+2960
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
[package]
name = "fselect"
version = "0.10.2"
authors = ["jhspetersson <jhspetersson@gmail.com>"]
description = "Find files with SQL-like queries"
keywords = ["find", "files", "sql", "query", "tool"]
categories = ["filesystem", "command-line-utilities", "command-line-interface"]
documentation = "https://github.com/jhspetersson/fselect/blob/master/docs/usage.md"
homepage = "https://github.com/jhspetersson/fselect"
repository = "https://github.com/jhspetersson/fselect"
readme = "README.md"
license = "MIT OR Apache-2.0"
edition = "2024"
[features]
default = ["git", "users", "update-notifications", "interactive"]
git = ["dep:git2"]
update-notifications = ["dep:update-informer"]
users = ["dep:uzers"]
interactive = ["dep:rustyline", "dep:directories"]
everything = []
plocate = []
[dependencies]
bytecount = "0.6"
chrono = "0.4"
chrono-english = "0.1"
csv = "1.0"
directories = { version = "6.0", optional = true }
git2 = { version = "0.21", default-features = false, optional = true }
human-time = "0.1.6"
humansize = "2.0"
imagesize = "0.14"
kamadak-exif = "0.6"
lofty = "0.24"
lscolors = { version = "0.21", features = [ "nu-ansi-term" ] }
matroska = "0.30"
memchr = "2"
mp4parse = "0.17"
nu-ansi-term = "0.50"
rand = "0.10"
rbase64 = "2.0"
regex = "1.1"
rustyline = { version = "18", optional = true }
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
sha1 = "0.11"
sha2 = "0.11"
sha3 = "0.12"
svg = "0.18"
toml = "1"
tree_magic_mini = { version = "3.0", features = [ "with-gpl-data" ] }
update-informer = { version = "1.1.0", optional = true }
wana_kana = "5.0"
zip = "8"
[target.'cfg(unix)'.dependencies]
uzers = { version = "0.12", optional = true }
xattr = "1.0"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Security_Authorization", "Win32_Security", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader"] }
[profile.release]
lto = true
codegen-units = 1
[package.metadata.deb]
section = "utility"
extended-description = """\
* SQL-like (not real SQL, but highly relaxed!) grammar easily understandable by humans
* complex queries, compare results in several directories with subqueries
* aggregate, statistics, date, and other functions
* search within archives
* .gitignore, .hgignore, and .dockerignore support (experimental)
* search by width and height of images, EXIF metadata
* search by audio metadata (MP3, FLAC, Ogg, Opus, M4A, WAV, and more)
* search by extended file attributes, POSIX ACLs, and Linux capabilities
* search by file hashes
* search by MIME type
* shortcuts to common file types
* interactive mode
* support for plocate and Everything indexes for lightning-fast search
* various output formatting (CSV, JSON, and others)"""
[package.metadata.generate-rpm]
release = "1"
assets = [
{ source = "target/release/fselect", dest = "/usr/bin/fselect", mode = "755" },
{ source = "LICENSE-MIT", dest = "/usr/share/doc/fselect/LICENSE-MIT", mode = "644", doc = true },
{ source = "LICENSE-APACHE", dest = "/usr/share/doc/fselect/LICENSE-APACHE", mode = "644", doc = true },
{ source = "README.md", dest = "/usr/share/doc/fselect/README.md", mode = "644", doc = true },
]
+8
View File
@@ -0,0 +1,8 @@
FROM rust:latest
WORKDIR /usr/src/fselect
COPY . .
RUN cargo install --locked --path .
CMD ["cargo", "test", "--locked" , "--verbose", "--all"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+23
View File
@@ -0,0 +1,23 @@
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+335
View File
@@ -0,0 +1,335 @@
# fselect
Find files with SQL-like queries
[![Crates.io](https://img.shields.io/crates/v/fselect.svg)](https://crates.io/crates/fselect)
[![build](https://github.com/jhspetersson/fselect/actions/workflows/rust.yml/badge.svg)](https://github.com/jhspetersson/fselect/actions/workflows/rust.yml)
### Why use fselect?
While it doesn't tend to fully replace traditional `find` and `ls`, **fselect** has these nice features:
* SQL-like (not real SQL, but highly relaxed!) grammar easily understandable by humans
* complex queries, compare results in several directories with [subqueries](docs/usage.md#subqueries-for-in-and-exists)
* aggregate, statistics, date, and other [functions](docs/usage.md#functions)
* search within archives
* `.gitignore`, `.hgignore`, and `.dockerignore` support (experimental)
* search by width and height of images, EXIF metadata
* search by audio metadata (MP3, FLAC, Ogg Vorbis, Opus, M4A/AAC, WAV, AIFF, APE, WavPack, Musepack, Speex)
* search by extended file attributes, POSIX ACLs, and Linux capabilities
* search by file hashes
* search by MIME type
* shortcuts to common file types
* [interactive mode](docs/usage.md#interactive-mode)
* support for `plocate` and `Everything` indexes for lightning-fast search
* various output formatting (CSV, JSON, and others)
More is under way!
### Installation
#### Latest release from source
* Install [Rust with Cargo](https://www.rust-lang.org/en-US/install.html) and its dependencies to build a binary
* Run `cargo install fselect`
You can optionally build with fast, index-backed search support: [voidtools *Everything*](https://www.voidtools.com/)
on Windows (`--features everything`, then pass `--everything`) or [*plocate*](https://plocate.sesse.net/)
on Linux (`--features plocate`, then pass `--plocate`). See the
[usage guide](docs/usage.md#index-backed-search-everything--plocate) for details.
#### Debian/Ubuntu
[deb package](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect_0.10.2-1_amd64.deb)
#### Fedora/RHEL/openSUSE
[rpm package](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-0.10.2-1.x86_64.rpm)
#### Arch Linux
[AUR package](https://aur.archlinux.org/packages/fselect/), thanks to [@asm0dey](https://github.com/asm0dey)
[AUR bin package](https://aur.archlinux.org/packages/fselect-bin/), thanks to [@4censord](https://github.com/4censord)
#### NixOS
[`fselect` in `nixpkgs`](https://github.com/filalex77/nixpkgs/blob/1eced92263395896c10cea69e5f60e8be5f43aeb/pkgs/tools/misc/fselect/default.nix), thanks to [@filalex77](https://github.com/filalex77)
#### Other Linux
* x86_64: [glibc](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-x86_64-linux.gz) &middot; [musl (static)](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-x86_64-linux-musl.gz)
* aarch64: [glibc](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-aarch64-linux.gz) &middot; [musl (static)](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-aarch64-linux-musl.gz)
#### Windows 64bit
A precompiled [binary](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-x86_64-win.zip)
#### Windows via winget
* Install [winget](https://github.com/microsoft/winget-cli)
* Run `winget install -e --id fselect.fselect`
#### Windows via Chocolatey
* Install [Chocolatey](https://chocolatey.org/install)
* Run `choco install fselect`
#### Windows via Scoop
* Install [Scoop](https://scoop.sh)
* Run `scoop install fselect`
#### macOS
Precompiled binaries are available at GitHub downloads (`gunzip`, then `chmod +x fselect`):
* [Intel (x86_64)](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-x86_64-mac.gz)
* [Apple Silicon (aarch64)](https://github.com/jhspetersson/fselect/releases/download/0.10.2/fselect-aarch64-mac.gz)
#### Mac via Homebrew
* Install [brew](https://brew.sh)
* Run `brew install fselect`
#### Mac via MacPorts
* Install [MacPorts](https://www.macports.org)
* Run:
```
sudo port selfupdate
sudo port install fselect
```
### Usage
fselect [ARGS] COLUMN[, COLUMN...] [from ROOT[, ROOT...]] [where EXPR] [group by COLUMNS] [order by COLUMNS] [limit N] [offset N] [into FORMAT]
### Interactive mode
fselect -i
### Documentation
[Detailed description of all the supported features.](docs/usage.md)
### Examples
Find temporary or config files (full path and size):
fselect size, path from /home/user where name = '*.cfg' or name = '*.tmp'
Windows users may omit the quotes:
fselect size, path from C:\Users\user where name = *.cfg or name = *.tmp
Or put all the arguments into the quotes like this:
fselect "name from /home/user/tmp where size > 0"
Search within a directory name with spaces (backticks are also supported):
fselect "name from '/home/user/dir with spaces' where size > 0"
fselect "name from `/home/user/dir with spaces` where size > 0"
Or simply escape the single quote:
fselect name from \'/home/user/dir with spaces\' where size gt 0
Specify the file size, get an absolute path, and add it to the results:
cd /home/user
fselect size, abspath from ./tmp where size gt 2g
fselect fsize, abspath from ./tmp where size = 5m
fselect hsize, abspath from ./tmp where size lt 8k
fselect name, size from ./tmp where size between 5mb and 6mb
More complex query:
fselect "name from /tmp where (name = *.tmp and size = 0) or (name = *.cfg and size > 1000000)"
You can use subqueries:
fselect "name from /test1 where size > 100 and size in (select size from /test2 where name in (select name from /test3 where modified in (select modified from /test4 where size < 200)))"
fselect "name, path, size from /data as data where exists (select * from /backup as backup where backup.name = data.name)"
A subquery can also be used in the `FROM` clause as the source of the outer query:
fselect "src.name, src.size from (select path from /projects depth 2 where size > 100) as src where src.name like '%.rs'"
Aggregate functions (you can use curly braces if you want and even combine them with the regular parentheses):
fselect "MIN(size), MAX{size}, AVG(size), SUM{size}, COUNT(*) from /home/user/Downloads"
Formatting functions:
fselect "LOWER(name), UPPER(name), LENGTH(name), YEAR(modified) from /home/user/Downloads"
Get the year of the oldest file:
fselect "MIN(YEAR(modified)) from /home/user"
Use single quotes if you need to address files with spaces:
fselect "path from '/home/user/Misc stuff' where name != 'Some file'"
Regular expressions of [Rust flavor](https://docs.rs/regex/1.1.0/regex/#syntax) are supported:
fselect name from /home/user where path =~ '.*Rust.*'
Negate regular expressions:
fselect "name from . where path !=~ '^\./config'"
Simple globs expand automatically and work with `=` and `!=` operators:
fselect name from /home/user where path = '*Rust*'
Classic LIKE:
fselect "path from /home/user where name like '%report-2018-__-__???'"
Exact match operators to search with regexps disabled:
fselect "path from /home/user where name === 'some_*_weird_*_name'"
Find files by date:
fselect path from /home/user where created = 2017-05-01
fselect path from /home/user where modified = today
fselect path from /home/user where accessed = yesterday
fselect "path from /home/user where modified = 'apr 1'"
fselect "path from /home/user where modified = 'last fri'"
Be more specific to match all files created at an interval between 3PM and 4PM:
fselect path from /home/user where created = '2017-05-01 15'
And even more specific:
fselect path from /home/user where created = '2017-05-01 15:10'
fselect path from /home/user where created = '2017-05-01 15:10:30'
Date and time intervals are possible (find everything updated since May 1st):
fselect path from /home/user where modified gte 2017-05-01
Default is the current directory:
fselect path, size where name = '*.jpg'
Search within multiple locations:
fselect path from /home/user/oldstuff, /home/user/newstuff where name = '*.jpg'
With minimum and/or maximum depth specified (`depth` is a synonym for `maxdepth`):
fselect path from /home/user/oldstuff depth 5 where name = '*.jpg'
fselect path from /home/user/oldstuff mindepth 2 maxdepth 5, /home/user/newstuff depth 10 where name = '*.jpg'
Optionally follow symlinks:
fselect path, size from /home/user symlinks where name = '*.jpg'
Search within archives (currently only zip-archives are supported):
fselect path, size from /home/user archives where name = '*.jpg'
Or in combination:
fselect size, path from /home/user depth 5 archives symlinks where name = '*.jpg' limit 100
Enable `.gitignore` or `.hgignore` support:
fselect size, path from /home/user/projects gitignore where name = '*.cpp'
fselect size, path from /home/user/projects git where name = '*.cpp'
fselect size, path from /home/user/projects hgignore where name = '*.py'
Search by image dimensions:
fselect CONCAT(width, 'x', height), path from /home/user/photos where width gte 2000 or height gte 2000
Find square images:
fselect path from /home/user/Photos where width = height
Find images with a known name part but unknown extension:
fselect path from /home/user/projects where name = "*RDS*" and width gte 1
Find old-school rap MP3 files:
fselect duration, path from /home/user/music where genre = Rap and bitrate = 320 and mp3_year lt 2000
Shortcuts to common file extensions:
fselect path from /home/user where is_archive = true
fselect path, mime from /home/user where is_audio = 1
fselect path, mime from /home/user where is_book != false
Even simpler way of using boolean columns:
fselect path from /home/user where is_doc
fselect path from /home/user where is_image
fselect path from /home/user where is_video
Find files with dangerous permissions:
fselect mode, path from /home/user where other_write or other_exec
fselect mode, path from /home/user where other_all
Simple glob-like expressions or even regular expressions in file mode are possible:
fselect mode, path from /home/user where mode = '*rwx'
fselect mode, path from /home/user where mode =~ '.*rwx$'
Find files by owner's uid or gid:
fselect uid, gid, path from /home/user where uid != 1000 or gid != 1000
Or by owner's or group's name:
fselect user, group, path from /home/user where user = mike or group = mike
Find special files:
fselect name from /usr/bin where suid
fselect path from /tmp where is_sticky
fselect path from /tmp where is_pipe
fselect path from /tmp where is_socket
Find files with xattrs, check if a particular xattr exists, or get its value:
fselect "path, has_xattrs, has_xattr(user.test), xattr(user.test) from /home/user"
Include arbitrary text as columns:
fselect "name, ' has size of ', size, ' bytes'"
Group results:
fselect "ext, count(*) from /tmp group by ext"
fselect "ext, count(*) from /tmp group by 1"
fselect "mime, ext, count(*) from /tmp group by 1, 2"
Order results:
fselect path from /tmp order by size desc, name
fselect modified, fsize, path from ~ order by 1 desc, 3
Finally, limit the results:
fselect name from /home/user/samples limit 5
Format output:
fselect size, path from /home/user limit 5 into json
fselect size, path from /home/user limit 5 into csv
fselect size, path from /home/user limit 5 into html
### License
MIT/Apache-2.0
---
Supported by [JetBrains IDEA](https://jb.gg/OpenSourceSupport) open source license
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`jhspetersson/fselect`
- 原始仓库:https://github.com/jhspetersson/fselect
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+1189
View File
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
<svg viewBox="0 0 1050 900" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.title { font: bold 24px sans-serif; fill: #2d3748; }
.subtitle { font: 14px sans-serif; fill: #718096; }
.label { font: bold 14px monospace; fill: #2d3748; }
.value { font: 14px monospace; fill: #4a5568; }
.comment { font: italic 12px sans-serif; fill: #718096; }
.segment-text { font: bold 16px monospace; fill: white; }
.separator-text { font: bold 16px monospace; fill: white; }
.bracket { stroke-width: 2; fill: none; }
.bracket-abspath { stroke: #e53e3e; }
.bracket-absdir { stroke: #dd6b20; }
.bracket-path { stroke: #38a169; }
.bracket-dir { stroke: #319795; }
.bracket-name { stroke: #d53f8c; }
.bracket-filename { stroke: #3182ce; }
.bracket-ext { stroke: #805ad5; }
</style>
</defs>
<!-- Background -->
<rect width="1050" height="900" fill="#f7fafc"/>
<!-- Title -->
<text x="500" y="40" text-anchor="middle" class="title">File Naming Terminology</text>
<text x="500" y="65" text-anchor="middle" class="subtitle">Visual breakdown of path components</text>
<!-- Search Root Info -->
<rect x="50" y="85" width="950" height="40" fill="#edf2f7" rx="4"/>
<rect x="50" y="85" width="4" height="40" fill="#667eea"/>
<text x="70" y="110" class="value">
<tspan fill="#667eea" font-weight="bold">Search Root:</tspan> /home/user/projects
</text>
<!-- File Path Segments -->
<g id="path-segments">
<!-- Root -->
<rect x="50" y="160" width="250" height="50" fill="#fc8181" rx="8 0 0 8"/>
<text x="175" y="190" text-anchor="middle" class="segment-text">/home/user/projects</text>
<!-- Separator 1 -->
<rect x="300" y="160" width="20" height="50" fill="#2d3748"/>
<text x="310" y="190" text-anchor="middle" class="separator-text">/</text>
<!-- Dir 1 -->
<rect x="320" y="160" width="100" height="50" fill="#f6ad55"/>
<text x="370" y="190" text-anchor="middle" class="segment-text">foobar</text>
<!-- Separator 2 -->
<rect x="420" y="160" width="20" height="50" fill="#2d3748"/>
<text x="430" y="190" text-anchor="middle" class="separator-text">/</text>
<!-- Dir 2 -->
<rect x="440" y="160" width="110" height="50" fill="#68d391"/>
<text x="495" y="190" text-anchor="middle" class="segment-text">content</text>
<!-- Separator 3 -->
<rect x="550" y="160" width="20" height="50" fill="#2d3748"/>
<text x="560" y="190" text-anchor="middle" class="separator-text">/</text>
<!-- Filename -->
<rect x="570" y="160" width="110" height="50" fill="#4299e1"/>
<text x="625" y="190" text-anchor="middle" class="segment-text">readme</text>
<!-- Extension -->
<rect x="680" y="160" width="60" height="50" fill="#9f7aea" rx="0 8 8 0"/>
<text x="710" y="190" text-anchor="middle" class="segment-text">.md</text>
</g>
<!-- Brackets and Labels -->
<!-- abspath -->
<path d="M 50 240 L 50 250 L 740 250 L 740 240" class="bracket bracket-abspath"/>
<line x1="395" y1="250" x2="395" y2="280" stroke="#e53e3e" stroke-width="2"/>
<rect x="50" y="280" width="690" height="35" fill="#fff5f5" stroke="#e53e3e" stroke-width="2" rx="4"/>
<text x="60" y="302" class="label" fill="#e53e3e">abspath</text>
<text x="200" y="302" class="value">/home/user/projects/foobar/content/readme.md</text>
<!-- absdir -->
<path d="M 50 345 L 50 355 L 550 355 L 550 345" class="bracket bracket-absdir"/>
<line x1="300" y1="355" x2="300" y2="385" stroke="#dd6b20" stroke-width="2"/>
<rect x="50" y="385" width="500" height="35" fill="#fffaf0" stroke="#dd6b20" stroke-width="2" rx="4"/>
<text x="60" y="407" class="label" fill="#dd6b20">absdir</text>
<text x="200" y="407" class="value">/home/user/projects/foobar/content</text>
<!-- path -->
<path d="M 320 450 L 320 460 L 740 460 L 740 450" class="bracket bracket-path"/>
<line x1="530" y1="460" x2="530" y2="490" stroke="#38a169" stroke-width="2"/>
<rect x="320" y="490" width="420" height="35" fill="#f0fff4" stroke="#38a169" stroke-width="2" rx="4"/>
<text x="330" y="512" class="label" fill="#38a169">path</text>
<text x="430" y="512" class="value">foobar/content/readme.md</text>
<!-- dir -->
<path d="M 320 555 L 320 565 L 550 565 L 550 555" class="bracket bracket-dir"/>
<line x1="435" y1="565" x2="435" y2="595" stroke="#319795" stroke-width="2"/>
<rect x="320" y="595" width="230" height="35" fill="#e6fffa" stroke="#319795" stroke-width="2" rx="4"/>
<text x="330" y="617" class="label" fill="#319795">dir</text>
<text x="430" y="617" class="value">foobar/content</text>
<!-- name -->
<path d="M 570 660 L 570 670 L 740 670 L 740 660" class="bracket bracket-name"/>
<line x1="655" y1="670" x2="655" y2="700" stroke="#d53f8c" stroke-width="2"/>
<rect x="570" y="700" width="170" height="35" fill="#fff5f7" stroke="#d53f8c" stroke-width="2" rx="4"/>
<text x="580" y="722" class="label" fill="#3182ce">name</text>
<text x="660" y="722" class="value">readme.md</text>
<!-- filename -->
<path d="M 570 765 L 570 775 L 680 775 L 680 765" class="bracket bracket-filename"/>
<line x1="625" y1="775" x2="625" y2="805" stroke="#3182ce" stroke-width="2"/>
<rect x="570" y="805" width="110" height="35" fill="#ebf8ff" stroke="#3182ce" stroke-width="2" rx="4"/>
<text x="580" y="827" class="label" fill="#805ad5">filename</text>
<!-- ext -->
<path d="M 680 765 L 680 775 L 740 775 L 740 765" class="bracket bracket-ext"/>
<line x1="710" y1="775" x2="710" y2="805" stroke="#805ad5" stroke-width="2"/>
<rect x="680" y="805" width="60" height="35" fill="#faf5ff" stroke="#805ad5" stroke-width="2" rx="4"/>
<text x="690" y="827" class="label" fill="#d53f8c">ext</text>
<!-- Legend -->
<g id="legend">
<text x="800" y="185" class="label">Legend</text>
<rect x="800" y="200" width="30" height="20" fill="#fc8181" rx="3"/>
<text x="840" y="215" class="comment">Root Directory</text>
<rect x="800" y="230" width="30" height="20" fill="#f6ad55" rx="3"/>
<text x="840" y="245" class="comment">Subdirectory 1</text>
<rect x="800" y="260" width="30" height="20" fill="#68d391" rx="3"/>
<text x="840" y="275" class="comment">Subdirectory 2</text>
<rect x="800" y="290" width="30" height="20" fill="#4299e1" rx="3"/>
<text x="840" y="305" class="comment">Filename</text>
<rect x="800" y="320" width="30" height="20" fill="#9f7aea" rx="3"/>
<text x="840" y="335" class="comment">Extension</text>
</g>
<!-- Comments on the right -->
<text x="800" y="407" class="comment">Absolute directory (without filename)</text>
<text x="800" y="512" class="comment">Path relative to search root</text>
<text x="800" y="617" class="comment">Relative directory</text>
<text x="800" y="722" class="comment">filename + ext</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

+1099
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
_fselect_complete() {
_init_completion || return
for ((i=COMP_CWORD-1; i>=0; i--)); do
local word="${COMP_WORDS[i]}"
if [[ "${word,,}" == "from" ]]; then
_filedir -d
elif [[ "${word,,}" == "into" ]]; then
local output_formats=$(fselect --output-formats)
COMPREPLY=($(compgen -W "$output_formats" -- "${COMP_WORDS[COMP_CWORD]}"))
else
local fields=$(fselect --fields)
local functions=$(fselect --functions)
COMPREPLY=($(compgen -W "$fields $functions" -- "${COMP_WORDS[COMP_CWORD]}"))
fi
done
}
complete -F _fselect_complete fselect
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
<rect width="200" height="100" fill="blue"/>
</svg>

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="144" width="144" xmlns="http://www.w3.org/2000/svg"><path d="m71.05 23.68c-26.06 0-47.27 21.22-47.27 47.27s21.22 47.27 47.27 47.27 47.27-21.22 47.27-47.27-21.22-47.27-47.27-47.27zm-.07 4.2a3.1 3.11 0 0 1 3.02 3.11 3.11 3.11 0 0 1 -6.22 0 3.11 3.11 0 0 1 3.2-3.11zm7.12 5.12a38.27 38.27 0 0 1 26.2 18.66l-3.67 8.28c-.63 1.43.02 3.11 1.44 3.75l7.06 3.13a38.27 38.27 0 0 1 .08 6.64h-3.93c-.39 0-.55.26-.55.64v1.8c0 4.24-2.39 5.17-4.49 5.4-2 .23-4.21-.84-4.49-2.06-1.18-6.63-3.14-8.04-6.24-10.49 3.85-2.44 7.85-6.05 7.85-10.87 0-5.21-3.57-8.49-6-10.1-3.42-2.25-7.2-2.7-8.22-2.7h-40.6a38.27 38.27 0 0 1 21.41-12.08l4.79 5.02c1.08 1.13 2.87 1.18 4 .09zm-44.2 23.02a3.11 3.11 0 0 1 3.02 3.11 3.11 3.11 0 0 1 -6.22 0 3.11 3.11 0 0 1 3.2-3.11zm74.15.14a3.11 3.11 0 0 1 3.02 3.11 3.11 3.11 0 0 1 -6.22 0 3.11 3.11 0 0 1 3.2-3.11zm-68.29.5h5.42v24.44h-10.94a38.27 38.27 0 0 1 -1.24-14.61l6.7-2.98c1.43-.64 2.08-2.31 1.44-3.74zm22.62.26h12.91c.67 0 4.71.77 4.71 3.8 0 2.51-3.1 3.41-5.65 3.41h-11.98zm0 17.56h9.89c.9 0 4.83.26 6.08 5.28.39 1.54 1.26 6.56 1.85 8.17.59 1.8 2.98 5.4 5.53 5.4h16.14a38.27 38.27 0 0 1 -3.54 4.1l-6.57-1.41c-1.53-.33-3.04.65-3.37 2.18l-1.56 7.28a38.27 38.27 0 0 1 -31.91-.15l-1.56-7.28c-.33-1.53-1.83-2.51-3.36-2.18l-6.43 1.38a38.27 38.27 0 0 1 -3.32-3.92h31.27c.35 0 .59-.06.59-.39v-11.06c0-.32-.24-.39-.59-.39h-9.15zm-14.43 25.33a3.11 3.11 0 0 1 3.02 3.11 3.11 3.11 0 0 1 -6.22 0 3.11 3.11 0 0 1 3.2-3.11zm46.05.14a3.11 3.11 0 0 1 3.02 3.11 3.11 3.11 0 0 1 -6.22 0 3.11 3.11 0 0 1 3.2-3.11z"/><path d="m115.68 70.95a44.63 44.63 0 0 1 -44.63 44.63 44.63 44.63 0 0 1 -44.63-44.63 44.63 44.63 0 0 1 44.63-44.63 44.63 44.63 0 0 1 44.63 44.63zm-.84-4.31 6.96 4.31-6.96 4.31 5.98 5.59-7.66 2.87 4.78 6.65-8.09 1.32 3.4 7.46-8.19-.29 1.88 7.98-7.98-1.88.29 8.19-7.46-3.4-1.32 8.09-6.65-4.78-2.87 7.66-5.59-5.98-4.31 6.96-4.31-6.96-5.59 5.98-2.87-7.66-6.65 4.78-1.32-8.09-7.46 3.4.29-8.19-7.98 1.88 1.88-7.98-8.19.29 3.4-7.46-8.09-1.32 4.78-6.65-7.66-2.87 5.98-5.59-6.96-4.31 6.96-4.31-5.98-5.59 7.66-2.87-4.78-6.65 8.09-1.32-3.4-7.46 8.19.29-1.88-7.98 7.98 1.88-.29-8.19 7.46 3.4 1.32-8.09 6.65 4.78 2.87-7.66 5.59 5.98 4.31-6.96 4.31 6.96 5.59-5.98 2.87 7.66 6.65-4.78 1.32 8.09 7.46-3.4-.29 8.19 7.98-1.88-1.88 7.98 8.19-.29-3.4 7.46 8.09 1.32-4.78 6.65 7.66 2.87z" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg height="foo" width="bar" xmlns="http://www.w3.org/2000/svg"><path/></svg>

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.
Binary file not shown.
+185
View File
@@ -0,0 +1,185 @@
//! Handles configuration loading and saving
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
const CONFIG_FILE: &str = "config.toml";
macro_rules! vec_of_strings {
($($str:literal),*) => {
Some(vec![
$(String::from($str)),*
])
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct Config {
pub no_color: Option<bool>,
pub gitignore: Option<bool>,
pub hgignore: Option<bool>,
pub dockerignore: Option<bool>,
pub is_zip_archive: Option<Vec<String>>,
pub is_archive: Option<Vec<String>>,
pub is_audio: Option<Vec<String>>,
pub is_book: Option<Vec<String>>,
pub is_doc: Option<Vec<String>>,
pub is_font: Option<Vec<String>>,
pub is_image: Option<Vec<String>>,
pub is_source: Option<Vec<String>>,
pub is_video: Option<Vec<String>>,
pub default_file_size_format: Option<String>,
pub us_dates: Option<bool>,
pub check_for_updates: Option<bool>,
#[serde(default)]
pub everything: Option<bool>,
#[serde(default)]
pub plocate: Option<bool>,
#[serde(skip_serializing, default = "get_false")]
pub debug: bool,
#[serde(skip)]
save: bool,
}
fn get_false() -> bool {
false
}
impl Config {
pub fn new() -> Result<Config, String> {
let mut config_file;
if let Some(cf) = Self::get_current_dir_config() {
config_file = cf;
} else {
let config_dir = crate::util::app_dirs::get_project_dir();
if config_dir.is_none() {
return Ok(Config::default());
}
config_file = config_dir.unwrap();
config_file.push(CONFIG_FILE);
if !config_file.exists() {
return Ok(Config::default());
}
}
Config::from(config_file)
}
pub fn from(config_file: PathBuf) -> Result<Config, String> {
if let Ok(mut file) = fs::File::open(config_file) {
let mut contents = String::new();
if file.read_to_string(&mut contents).is_ok() {
toml::from_str(&contents).map_err(|err| err.to_string())
} else {
Err("Could not read config file. Using default settings.".to_string())
}
} else {
Err("Could not open config file. Using default settings.".to_string())
}
}
fn get_current_dir_config() -> Option<PathBuf> {
if let Ok(mut pb) = std::env::current_exe() {
pb.pop();
pb.push(CONFIG_FILE);
if pb.exists() {
return Some(pb);
}
}
None
}
pub fn save(&self) {
if !self.save {
return;
}
let config_dir = crate::util::app_dirs::get_project_dir();
if config_dir.is_none() {
return;
}
let mut config_file = config_dir.unwrap();
let _ = fs::create_dir_all(&config_file);
config_file.push(CONFIG_FILE);
if config_file.exists() {
return;
}
let toml = toml::to_string_pretty(&self).unwrap();
if let Ok(mut file) = fs::File::create(&config_file) {
let _ = file.write_all(toml.as_bytes());
}
}
pub fn default() -> Config {
Config {
no_color: Some(false),
gitignore: Some(false),
hgignore: Some(false),
dockerignore: Some(false),
is_zip_archive: vec_of_strings![".zip", ".jar", ".war", ".ear"],
is_archive: vec_of_strings![
".7z", ".bz2", ".bzip2", ".gz", ".gzip", ".lz", ".rar", ".tar", ".xz", ".zip"
],
is_audio: vec_of_strings![
".aac", ".aiff", ".amr", ".flac", ".gsm", ".m4a", ".m4b", ".m4p", ".mp3", ".ogg",
".wav", ".wma"
],
is_book: vec_of_strings![
".azw3", ".chm", ".djv", ".djvu", ".epub", ".fb2", ".mobi", ".pdf"
],
is_doc: vec_of_strings![
".accdb", ".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".mdb", ".odp",
".ods", ".odt", ".pdf", ".potm", ".potx", ".ppt", ".pptm", ".pptx", ".rtf", ".xlm",
".xls", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".xps"
],
is_font: vec_of_strings![
".eot", ".fon", ".otc", ".otf", ".ttc", ".ttf", ".woff", ".woff2"
],
is_image: vec_of_strings![
".bmp", ".exr", ".gif", ".heic", ".jpeg", ".jpg", ".jxl", ".png", ".psb", ".psd",
".svg", ".tga", ".tiff", ".webp"
],
is_source: vec_of_strings![
".asm", ".awk", ".bas", ".c", ".cc", ".ceylon", ".clj", ".coffee", ".cpp", ".cs", ".d",
".dart", ".elm", ".erl", ".go", ".gradle", ".groovy", ".h", ".hh", ".hpp", ".java",
".jl", ".js", ".jsp", ".jsx", ".kt", ".kts", ".lua", ".nim", ".pas", ".php", ".pl",
".pm", ".py", ".qml", ".rb", ".rs", ".scala", ".sol", ".swift", ".tcl", ".ts", ".tsx",
".vala", ".vb", ".zig"
],
is_video: vec_of_strings![
".3gp", ".avi", ".flv", ".m4p", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg",
".webm", ".wmv"
],
default_file_size_format: Some(String::new()),
us_dates: Some(false),
check_for_updates: Some(false),
everything: Some(false),
plocate: Some(false),
debug: false,
save: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let config = Config::default();
assert!(config.is_source.unwrap().contains(&String::from(".rs")));
}
}
+1086
View File
File diff suppressed because it is too large Load Diff
+366
View File
@@ -0,0 +1,366 @@
use crate::field::Field;
use crate::field::context::FieldContext;
use crate::util::*;
use crate::util::error::SearchError;
pub fn handle_line_count(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_line_count(ctx.entry);
if let Some(line_count) = ctx.fms.get_line_count() {
return Ok(Variant::from_int(line_count as i64));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_word_count(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_content_stats(ctx.entry);
if let Some(stats) = ctx.fms.get_content_stats()
&& stats.is_text {
return Ok(Variant::from_int(stats.word_count as i64));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_char_count(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_content_stats(ctx.entry);
if let Some(stats) = ctx.fms.get_content_stats()
&& stats.is_text {
return Ok(Variant::from_int(stats.char_count as i64));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_encoding(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_content_stats(ctx.entry);
if let Some(stats) = ctx.fms.get_content_stats()
&& stats.is_text {
return Ok(Variant::from_string(&stats.encoding));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_has_bom(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
if let Some(stats) = ctx.fms.get_content_stats() {
return Ok(Variant::from_bool(stats.has_bom));
}
Ok(Variant::from_bool(has_bom(ctx.entry)))
}
pub fn handle_line_ending(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_content_stats(ctx.entry);
if let Some(stats) = ctx.fms.get_content_stats()
&& stats.is_text {
return Ok(Variant::from_string(&stats.line_ending));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_mime(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_mime_type(ctx.entry);
if let Some(mime) = ctx.fms.get_mime_type() {
return Ok(Variant::from_string(&String::from(mime)));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_is_binary_or_text(ctx: &mut FieldContext, field: &Field) -> Result<Variant, SearchError> {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(meta) = ctx.fms.get_file_metadata()
&& meta.is_dir() {
return Ok(Variant::from_bool(false));
}
ctx.fms.update_mime_type(ctx.entry);
if let Some(mime) = ctx.fms.get_mime_type() {
let is_text = is_text_mime(mime);
let result = matches!(field, Field::IsText) == is_text;
return Ok(Variant::from_bool(result));
}
Ok(Variant::from_bool(false))
}
pub fn handle_is_type(ctx: &mut FieldContext, field: &Field) -> Result<Variant, SearchError> {
let os_name = ctx.entry.file_name();
let name = match ctx.file_info {
Some(fi) => fi.name.as_str(),
None => &os_name.to_string_lossy(),
};
let result = match field {
Field::IsArchive => check_extension(name, &ctx.config.is_archive, &ctx.default_config.is_archive),
Field::IsAudio => check_extension(name, &ctx.config.is_audio, &ctx.default_config.is_audio),
Field::IsBook => check_extension(name, &ctx.config.is_book, &ctx.default_config.is_book),
Field::IsDoc => check_extension(name, &ctx.config.is_doc, &ctx.default_config.is_doc),
Field::IsFont => check_extension(name, &ctx.config.is_font, &ctx.default_config.is_font),
Field::IsImage => check_extension(name, &ctx.config.is_image, &ctx.default_config.is_image),
Field::IsSource => check_extension(name, &ctx.config.is_source, &ctx.default_config.is_source),
Field::IsVideo => check_extension(name, &ctx.config.is_video, &ctx.default_config.is_video),
_ => return Err(SearchError::fatal(format!("Unexpected field in handle_is_type: {:?}", field))),
};
Ok(Variant::from_bool(result))
}
fn check_extension(
file_name: &str,
config_ext: &Option<Vec<String>>,
default_ext: &Option<Vec<String>>,
) -> bool {
match config_ext.as_ref().or(default_ext.as_ref()) {
Some(extensions) => has_extension(file_name, extensions),
None => false,
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use crate::config::Config;
use super::*;
fn test_field(entry: &fs::DirEntry, root_path: &Path, field: &Field) -> Variant {
let config = Config::default();
let default_config = Config::default();
let mut fms = crate::field::context::FileMetadataState::new();
#[cfg(feature = "git")]
let mut git_cache = crate::util::git::GitCache::new();
#[cfg(all(unix, feature = "users"))]
let user_cache = uzers::UsersCache::new();
let none_file_info = None;
let mut ctx = FieldContext {
entry,
file_info: &none_file_info,
root_path,
fms: &mut fms,
#[cfg(feature = "git")]
git_cache: &mut git_cache,
follow_symlinks: true,
config: &config,
default_config: &default_config,
#[cfg(all(unix, feature = "users"))]
user_cache: &user_cache,
};
crate::field::dispatch::get_field_value(&mut ctx, field).unwrap()
}
fn entry_for(dir: &Path, name: &str) -> fs::DirEntry {
fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name() == name)
.unwrap()
}
#[test]
fn test_content_fields_on_text_file() {
let tmp = std::env::temp_dir().join("fselect_test_content_fields_text_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("a.txt"), "hello world\nsecond line\n").unwrap();
let entry = entry_for(&tmp, "a.txt");
assert_eq!(test_field(&entry, &tmp, &Field::WordCount).to_string(), "4");
assert_eq!(test_field(&entry, &tmp, &Field::CharCount).to_string(), "24");
assert_eq!(test_field(&entry, &tmp, &Field::Encoding).to_string(), "ASCII");
assert_eq!(test_field(&entry, &tmp, &Field::HasBom).to_string(), "false");
assert_eq!(test_field(&entry, &tmp, &Field::LineEnding).to_string(), "LF");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_content_fields_on_binary_file() {
let tmp = std::env::temp_dir().join("fselect_test_content_fields_binary_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("a.bin"), [0x00u8, 0x01, 0x02, b'x']).unwrap();
let entry = entry_for(&tmp, "a.bin");
// Text-only fields surface an empty value for binary content.
assert_eq!(test_field(&entry, &tmp, &Field::WordCount).to_string(), "");
assert_eq!(test_field(&entry, &tmp, &Field::CharCount).to_string(), "");
assert_eq!(test_field(&entry, &tmp, &Field::Encoding).to_string(), "");
assert_eq!(test_field(&entry, &tmp, &Field::LineEnding).to_string(), "");
// has_bom is still a definite boolean.
assert_eq!(test_field(&entry, &tmp, &Field::HasBom).to_string(), "false");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_has_bom_true_for_utf8_bom_file() {
let tmp = std::env::temp_dir().join("fselect_test_content_fields_bom_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
let mut content = vec![0xEFu8, 0xBB, 0xBF];
content.extend_from_slice(b"data");
fs::write(tmp.join("bom.txt"), content).unwrap();
let entry = entry_for(&tmp, "bom.txt");
assert_eq!(test_field(&entry, &tmp, &Field::HasBom).to_string(), "true");
assert_eq!(test_field(&entry, &tmp, &Field::Encoding).to_string(), "UTF-8");
let _ = fs::remove_dir_all(&tmp);
}
fn is_archive(config: &Config, default_config: &Config, name: &str) -> bool {
check_extension(name, &config.is_archive, &default_config.is_archive)
}
fn is_audio(config: &Config, default_config: &Config, name: &str) -> bool {
check_extension(name, &config.is_audio, &default_config.is_audio)
}
fn is_book(config: &Config, default_config: &Config, name: &str) -> bool {
check_extension(name, &config.is_book, &default_config.is_book)
}
fn is_doc(config: &Config, default_config: &Config, name: &str) -> bool {
check_extension(name, &config.is_doc, &default_config.is_doc)
}
fn is_font(config: &Config, default_config: &Config, name: &str) -> bool {
check_extension(name, &config.is_font, &default_config.is_font)
}
fn is_image(config: &Config, default_config: &Config, name: &str) -> bool {
check_extension(name, &config.is_image, &default_config.is_image)
}
fn is_source(config: &Config, default_config: &Config, name: &str) -> bool {
check_extension(name, &config.is_source, &default_config.is_source)
}
fn is_video(config: &Config, default_config: &Config, name: &str) -> bool {
check_extension(name, &config.is_video, &default_config.is_video)
}
#[test]
fn test_is_archive() {
let config = Config::default();
let default_config = Config::default();
assert!(is_archive(&config, &default_config, "test.zip"));
assert!(is_archive(&config, &default_config, "test.tar"));
assert!(is_archive(&config, &default_config, "test.gz"));
assert!(is_archive(&config, &default_config, "test.rar"));
assert!(!is_archive(&config, &default_config, "test.txt"));
assert!(!is_archive(&config, &default_config, "test.jpg"));
assert!(!is_archive(&config, &default_config, "test"));
}
#[test]
fn test_is_audio() {
let config = Config::default();
let default_config = Config::default();
assert!(is_audio(&config, &default_config, "test.mp3"));
assert!(is_audio(&config, &default_config, "test.wav"));
assert!(is_audio(&config, &default_config, "test.flac"));
assert!(is_audio(&config, &default_config, "test.ogg"));
assert!(!is_audio(&config, &default_config, "test.txt"));
assert!(!is_audio(&config, &default_config, "test.jpg"));
assert!(!is_audio(&config, &default_config, "test"));
}
#[test]
fn test_is_book() {
let config = Config::default();
let default_config = Config::default();
assert!(is_book(&config, &default_config, "test.pdf"));
assert!(is_book(&config, &default_config, "test.epub"));
assert!(is_book(&config, &default_config, "test.mobi"));
assert!(is_book(&config, &default_config, "test.djvu"));
assert!(!is_book(&config, &default_config, "test.txt"));
assert!(!is_book(&config, &default_config, "test.jpg"));
assert!(!is_book(&config, &default_config, "test"));
}
#[test]
fn test_is_doc() {
let config = Config::default();
let default_config = Config::default();
assert!(is_doc(&config, &default_config, "test.doc"));
assert!(is_doc(&config, &default_config, "test.docx"));
assert!(is_doc(&config, &default_config, "test.pdf"));
assert!(is_doc(&config, &default_config, "test.xls"));
assert!(!is_doc(&config, &default_config, "test.txt"));
assert!(!is_doc(&config, &default_config, "test.jpg"));
assert!(!is_doc(&config, &default_config, "test"));
}
#[test]
fn test_is_font() {
let config = Config::default();
let default_config = Config::default();
assert!(is_font(&config, &default_config, "test.ttf"));
assert!(is_font(&config, &default_config, "test.otf"));
assert!(is_font(&config, &default_config, "test.woff"));
assert!(is_font(&config, &default_config, "test.woff2"));
assert!(!is_font(&config, &default_config, "test.txt"));
assert!(!is_font(&config, &default_config, "test.jpg"));
assert!(!is_font(&config, &default_config, "test"));
}
#[test]
fn test_is_image() {
let config = Config::default();
let default_config = Config::default();
assert!(is_image(&config, &default_config, "test.jpg"));
assert!(is_image(&config, &default_config, "test.png"));
assert!(is_image(&config, &default_config, "test.gif"));
assert!(is_image(&config, &default_config, "test.svg"));
assert!(!is_image(&config, &default_config, "test.txt"));
assert!(!is_image(&config, &default_config, "test.mp3"));
assert!(!is_image(&config, &default_config, "test"));
}
#[test]
fn test_is_source() {
let config = Config::default();
let default_config = Config::default();
assert!(is_source(&config, &default_config, "test.rs"));
assert!(is_source(&config, &default_config, "test.c"));
assert!(is_source(&config, &default_config, "test.cpp"));
assert!(is_source(&config, &default_config, "test.java"));
assert!(!is_source(&config, &default_config, "test.txt"));
assert!(!is_source(&config, &default_config, "test.jpg"));
assert!(!is_source(&config, &default_config, "test"));
}
#[test]
fn test_check_extension_both_none() {
assert!(!check_extension("test.zip", &None, &None));
}
#[test]
fn test_is_video() {
let config = Config::default();
let default_config = Config::default();
assert!(is_video(&config, &default_config, "test.mp4"));
assert!(is_video(&config, &default_config, "test.avi"));
assert!(is_video(&config, &default_config, "test.mkv"));
assert!(is_video(&config, &default_config, "test.mov"));
assert!(!is_video(&config, &default_config, "test.txt"));
assert!(!is_video(&config, &default_config, "test.jpg"));
assert!(!is_video(&config, &default_config, "test"));
}
}
+282
View File
@@ -0,0 +1,282 @@
use std::collections::HashMap;
use std::fs::{DirEntry, FileType, Metadata};
use std::path::Path;
#[cfg(all(unix, feature = "users"))]
use uzers::UsersCache;
use crate::config::Config;
use crate::fileinfo::FileInfo;
use crate::util::*;
#[cfg(feature = "git")]
use crate::util::git::GitCache;
use crate::util::audio::{AudioInfo, get_audio_info};
use crate::util::dimensions::get_dimensions;
use crate::util::duration::get_duration;
pub struct FileMetadataState {
pub(crate) file_metadata: Option<Option<Metadata>>,
pub(crate) entry_file_type: Option<Option<FileType>>,
pub(crate) content_stats: Option<Option<ContentStats>>,
pub(crate) dimensions: Option<Option<Dimensions>>,
pub(crate) duration: Option<Option<Duration>>,
pub(crate) audio_info: Option<Option<AudioInfo>>,
pub(crate) exif_metadata: Option<Option<HashMap<String, String>>>,
pub(crate) mime_type: Option<Option<String>>,
pub(crate) sha1_hash: Option<String>,
pub(crate) sha256_hash: Option<String>,
pub(crate) sha512_hash: Option<String>,
pub(crate) sha3_hash: Option<String>,
}
impl FileMetadataState {
pub fn new() -> FileMetadataState {
FileMetadataState {
file_metadata: None,
entry_file_type: None,
content_stats: None,
dimensions: None,
duration: None,
audio_info: None,
exif_metadata: None,
mime_type: None,
sha1_hash: None,
sha256_hash: None,
sha512_hash: None,
sha3_hash: None,
}
}
pub fn clear(&mut self) {
*self = Self::new();
}
pub fn update_file_metadata(&mut self, entry: &DirEntry, follow_symlinks: bool) {
if self.file_metadata.is_none() {
self.file_metadata = Some(get_metadata(entry, follow_symlinks));
}
}
pub fn get_file_metadata(&self) -> Option<&Metadata> {
self.file_metadata.as_ref().and_then(|o| o.as_ref())
}
pub fn get_file_metadata_as_option(&self) -> &Option<Metadata> {
static NONE: Option<Metadata> = None;
self.file_metadata.as_ref().unwrap_or(&NONE)
}
/// Whether a metadata load has already been attempted for the current file
/// (regardless of whether it succeeded). Lets type predicates reuse it
/// instead of issuing a fresh stat.
pub fn file_metadata_loaded(&self) -> bool {
self.file_metadata.is_some()
}
/// Seed the entry's file type from a value the caller already resolved
/// (e.g. the directory traversal's descent check), so type predicates can
/// reuse it. A `None` hint is ignored, leaving the slot to be filled lazily
/// on first use.
pub fn seed_file_type(&mut self, file_type: Option<FileType>) {
if file_type.is_some() {
self.entry_file_type = Some(file_type);
}
}
/// The entry's file type, computed once and memoised. Reflects the entry
/// itself (symlinks are not followed), so it answers is_symlink directly
/// and is_dir/is_file only when not following symlinks.
pub fn get_or_compute_file_type(&mut self, entry: &DirEntry) -> Option<FileType> {
if self.entry_file_type.is_none() {
self.entry_file_type = Some(entry.file_type().ok());
}
self.entry_file_type.flatten()
}
pub fn update_line_count(&mut self, entry: &DirEntry) {
// Always derived from the content-stats pass: a raw newline-byte scan
// disagrees with the decoded text for UTF-16/32 files (0x0A occurs
// inside multibyte code units), which made the reported count depend
// on which content field happened to be evaluated first.
self.update_content_stats(entry);
}
pub fn get_line_count(&self) -> Option<usize> {
self.get_content_stats().map(|stats| stats.line_count)
}
pub fn update_content_stats(&mut self, entry: &DirEntry) {
if self.content_stats.is_none() {
self.content_stats = Some(get_content_stats(entry));
}
}
pub fn get_content_stats(&self) -> Option<&ContentStats> {
self.content_stats.as_ref().and_then(|o| o.as_ref())
}
pub fn update_audio_info(&mut self, entry: &DirEntry) {
if self.audio_info.is_none() {
self.audio_info = Some(get_audio_info(&entry.path()));
}
}
pub fn get_audio_info(&self) -> Option<&AudioInfo> {
self.audio_info.as_ref().and_then(|o| o.as_ref())
}
pub fn update_exif_metadata(&mut self, entry: &DirEntry) {
if self.exif_metadata.is_none() {
self.exif_metadata = Some(get_exif_metadata(entry));
}
}
pub fn get_exif_metadata(&self) -> Option<&HashMap<String, String>> {
self.exif_metadata.as_ref().and_then(|o| o.as_ref())
}
pub fn get_exif_string(&mut self, entry: &DirEntry, key: &str) -> Option<Variant> {
self.update_exif_metadata(entry);
self.get_exif_metadata()
.and_then(|info| info.get(key))
.map(Variant::from_string)
}
pub fn update_mime_type(&mut self, entry: &DirEntry) {
if self.mime_type.is_none() {
self.mime_type = Some(
tree_magic_mini::from_filepath(&entry.path()).map(String::from)
);
}
}
pub fn get_mime_type(&self) -> Option<&str> {
self.mime_type.as_ref().and_then(|o| o.as_deref())
}
pub fn get_or_compute_sha1(&mut self, entry: &DirEntry) -> &str {
if self.sha1_hash.is_none() {
self.sha1_hash = Some(get_sha1_file_hash(entry));
}
self.sha1_hash.as_deref().unwrap()
}
pub fn get_or_compute_sha256(&mut self, entry: &DirEntry) -> &str {
if self.sha256_hash.is_none() {
self.sha256_hash = Some(get_sha256_file_hash(entry));
}
self.sha256_hash.as_deref().unwrap()
}
pub fn get_or_compute_sha512(&mut self, entry: &DirEntry) -> &str {
if self.sha512_hash.is_none() {
self.sha512_hash = Some(get_sha512_file_hash(entry));
}
self.sha512_hash.as_deref().unwrap()
}
pub fn get_or_compute_sha3(&mut self, entry: &DirEntry) -> &str {
if self.sha3_hash.is_none() {
self.sha3_hash = Some(get_sha3_512_file_hash(entry));
}
self.sha3_hash.as_deref().unwrap()
}
pub fn update_dimensions(&mut self, entry: &DirEntry) {
if self.dimensions.is_none() {
self.dimensions = Some(get_dimensions(entry.path()));
}
}
pub fn get_dimensions(&self) -> Option<&Dimensions> {
self.dimensions.as_ref().and_then(|o| o.as_ref())
}
pub fn update_duration(&mut self, entry: &DirEntry) {
if self.duration.is_none() {
// Audio durations come from lofty (via the cached audio info);
// anything it doesn't handle falls back to the video extractors.
self.update_audio_info(entry);
let duration = self
.get_audio_info()
.and_then(|info| info.duration)
.map(|length| Duration { length })
.or_else(|| get_duration(entry.path()));
self.duration = Some(duration);
}
}
pub fn get_duration(&self) -> Option<&Duration> {
self.duration.as_ref().and_then(|o| o.as_ref())
}
}
pub struct FieldContext<'a> {
pub entry: &'a DirEntry,
pub file_info: &'a Option<FileInfo>,
pub root_path: &'a Path,
pub fms: &'a mut FileMetadataState,
#[cfg(feature = "git")]
pub git_cache: &'a mut GitCache,
pub follow_symlinks: bool,
pub config: &'a Config,
pub default_config: &'a Config,
#[cfg(all(unix, feature = "users"))]
pub user_cache: &'a UsersCache,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_metadata_state_new() {
let state = FileMetadataState::new();
assert!(state.file_metadata.is_none());
assert!(state.entry_file_type.is_none());
assert!(state.content_stats.is_none());
assert!(state.dimensions.is_none());
assert!(state.duration.is_none());
assert!(state.audio_info.is_none());
assert!(state.exif_metadata.is_none());
assert!(state.mime_type.is_none());
assert!(state.sha1_hash.is_none());
assert!(state.sha256_hash.is_none());
assert!(state.sha512_hash.is_none());
assert!(state.sha3_hash.is_none());
}
#[test]
fn test_file_metadata_state_clear() {
let mut state = FileMetadataState::new();
state.file_metadata = Some(None);
state.entry_file_type = Some(None);
state.content_stats = Some(None);
state.dimensions = Some(None);
state.duration = Some(None);
state.audio_info = Some(None);
state.exif_metadata = Some(None);
state.mime_type = Some(None);
state.sha1_hash = Some(String::new());
state.sha256_hash = Some(String::new());
state.sha512_hash = Some(String::new());
state.sha3_hash = Some(String::new());
state.clear();
assert!(state.file_metadata.is_none());
assert!(state.entry_file_type.is_none());
assert!(state.content_stats.is_none());
assert!(state.dimensions.is_none());
assert!(state.duration.is_none());
assert!(state.audio_info.is_none());
assert!(state.exif_metadata.is_none());
assert!(state.mime_type.is_none());
assert!(state.sha1_hash.is_none());
assert!(state.sha256_hash.is_none());
assert!(state.sha512_hash.is_none());
assert!(state.sha3_hash.is_none());
}
}
+188
View File
@@ -0,0 +1,188 @@
use crate::field::Field;
use crate::field::context::FieldContext;
use crate::field::{
content_handlers, exif_handlers, git_handlers, hash_handlers, media_handlers,
metadata_handlers, mode_handlers, path_handlers,
};
use crate::util::*;
use crate::util::error::SearchError;
pub fn get_field_value(ctx: &mut FieldContext, field: &Field) -> Result<Variant, SearchError> {
if ctx.file_info.is_some() && !field.is_available_for_archived_files() {
return Ok(Variant::empty(VariantType::String));
}
match field {
// Path fields
Field::Name => path_handlers::handle_name(ctx),
Field::Filename => path_handlers::handle_filename(ctx),
Field::Extension => path_handlers::handle_extension(ctx),
Field::Path => path_handlers::handle_path(ctx),
Field::AbsPath => path_handlers::handle_abspath(ctx),
Field::Directory => path_handlers::handle_directory(ctx),
Field::AbsDir => path_handlers::handle_absdir(ctx),
// Size / type metadata
Field::Size => metadata_handlers::handle_size(ctx),
Field::FormattedSize => metadata_handlers::handle_formatted_size(ctx),
Field::IsDir => metadata_handlers::handle_is_dir(ctx),
Field::IsFile => metadata_handlers::handle_is_file(ctx),
Field::IsSymlink => metadata_handlers::handle_is_symlink(ctx),
Field::LinkTarget => metadata_handlers::handle_link_target(ctx),
Field::IsBrokenSymlink => metadata_handlers::handle_is_broken_symlink(ctx),
Field::IsPipe => metadata_handlers::handle_is_pipe(ctx),
Field::IsCharacterDevice => metadata_handlers::handle_is_char_device(ctx),
Field::IsBlockDevice => metadata_handlers::handle_is_block_device(ctx),
Field::IsSocket => metadata_handlers::handle_is_socket(ctx),
Field::Device => metadata_handlers::handle_device(ctx),
Field::Rdev => metadata_handlers::handle_rdev(ctx),
Field::Inode => metadata_handlers::handle_inode(ctx),
Field::Blocks => metadata_handlers::handle_blocks(ctx),
Field::BlockSize => metadata_handlers::handle_blksize(ctx),
Field::Hardlinks => metadata_handlers::handle_hardlinks(ctx),
Field::Atime => metadata_handlers::handle_atime(ctx),
Field::AtimeNsec => metadata_handlers::handle_atime_nsec(ctx),
Field::Mtime => metadata_handlers::handle_mtime(ctx),
Field::MtimeNsec => metadata_handlers::handle_mtime_nsec(ctx),
Field::Ctime => metadata_handlers::handle_ctime(ctx),
Field::CtimeNsec => metadata_handlers::handle_ctime_nsec(ctx),
Field::Created => metadata_handlers::handle_created(ctx),
Field::Accessed => metadata_handlers::handle_accessed(ctx),
Field::Modified => metadata_handlers::handle_modified(ctx),
Field::IsHidden => metadata_handlers::handle_is_hidden(ctx),
Field::IsEmpty => metadata_handlers::handle_is_empty(ctx),
Field::HasXattrs => metadata_handlers::handle_has_xattrs(ctx),
Field::XattrCount => metadata_handlers::handle_xattr_count(ctx),
Field::Extattrs => metadata_handlers::handle_extattrs(ctx),
Field::HasExtattrs => metadata_handlers::handle_has_extattrs(ctx),
Field::Acl => metadata_handlers::handle_acl(ctx),
Field::HasAcl => metadata_handlers::handle_has_acl(ctx),
Field::DefaultAcl => metadata_handlers::handle_default_acl(ctx),
Field::HasDefaultAcl => metadata_handlers::handle_has_default_acl(ctx),
Field::HasCapabilities => metadata_handlers::handle_has_capabilities(ctx),
Field::Capabilities => metadata_handlers::handle_capabilities(ctx),
Field::IsShebang => metadata_handlers::handle_is_shebang(ctx),
// Git
Field::IsGitRepo => git_handlers::handle_is_git_repo(ctx),
#[cfg(feature = "git")]
Field::IsGitTracked => git_handlers::handle_is_git_tracked(ctx),
#[cfg(feature = "git")]
Field::IsGitignored => git_handlers::handle_is_gitignored(ctx),
#[cfg(feature = "git")]
Field::GitStatus => git_handlers::handle_git_status(ctx),
#[cfg(feature = "git")]
Field::GitBranch => git_handlers::handle_git_branch(ctx),
#[cfg(feature = "git")]
Field::GitLastCommitHash => git_handlers::handle_git_last_commit_hash(ctx),
#[cfg(feature = "git")]
Field::GitLastCommitDate => git_handlers::handle_git_last_commit_date(ctx),
#[cfg(feature = "git")]
Field::GitLastCommitAuthor => git_handlers::handle_git_last_commit_author(ctx),
// Mode / permissions
Field::Mode => mode_handlers::handle_mode(ctx),
Field::UserRead => mode_handlers::handle_user_read(ctx),
Field::UserWrite => mode_handlers::handle_user_write(ctx),
Field::UserExec => mode_handlers::handle_user_exec(ctx),
Field::UserAll => mode_handlers::handle_user_all(ctx),
Field::GroupRead => mode_handlers::handle_group_read(ctx),
Field::GroupWrite => mode_handlers::handle_group_write(ctx),
Field::GroupExec => mode_handlers::handle_group_exec(ctx),
Field::GroupAll => mode_handlers::handle_group_all(ctx),
Field::OtherRead => mode_handlers::handle_other_read(ctx),
Field::OtherWrite => mode_handlers::handle_other_write(ctx),
Field::OtherExec => mode_handlers::handle_other_exec(ctx),
Field::OtherAll => mode_handlers::handle_other_all(ctx),
Field::Suid => mode_handlers::handle_suid(ctx),
Field::Sgid => mode_handlers::handle_sgid(ctx),
Field::IsSticky => mode_handlers::handle_is_sticky(ctx),
Field::Uid => mode_handlers::handle_uid(ctx),
Field::Gid => mode_handlers::handle_gid(ctx),
#[cfg(all(unix, feature = "users"))]
Field::User => mode_handlers::handle_user(ctx),
#[cfg(all(unix, feature = "users"))]
Field::Group => mode_handlers::handle_group(ctx),
// Media (dimensions, audio)
Field::Width => media_handlers::handle_width(ctx),
Field::Height => media_handlers::handle_height(ctx),
Field::Duration => media_handlers::handle_duration(ctx),
Field::Bitrate => media_handlers::handle_bitrate(ctx),
Field::Freq => media_handlers::handle_freq(ctx),
Field::Title => media_handlers::handle_title(ctx),
Field::Artist => media_handlers::handle_artist(ctx),
Field::Album => media_handlers::handle_album(ctx),
Field::Year => media_handlers::handle_year(ctx),
Field::Genre => media_handlers::handle_genre(ctx),
Field::Comment => media_handlers::handle_comment(ctx),
Field::Track => media_handlers::handle_track(ctx),
Field::Disc => media_handlers::handle_disc(ctx),
// EXIF
Field::ExifDateTime | Field::ExifDateTimeOriginal => {
exif_handlers::handle_exif_datetime(ctx, field)
}
Field::ExifGpsAltitude => exif_handlers::handle_exif_gps_altitude(ctx),
Field::ExifGpsLatitude => exif_handlers::handle_exif_gps_latitude(ctx),
Field::ExifGpsLongitude => exif_handlers::handle_exif_gps_longitude(ctx),
Field::ExifMake
| Field::ExifModel
| Field::ExifSoftware
| Field::ExifVersion
| Field::ExifExposureTime
| Field::ExifAperture
| Field::ExifShutterSpeed
| Field::ExifFNumber
| Field::ExifIsoSpeed
| Field::ExifPhotographicSensitivity
| Field::ExifFocalLength
| Field::ExifLensMake
| Field::ExifLensModel
| Field::ExifDescription
| Field::ExifArtist
| Field::ExifCopyright
| Field::ExifOrientation
| Field::ExifFlash
| Field::ExifColorSpace
| Field::ExifExposureProgram
| Field::ExifExposureBias
| Field::ExifWhiteBalance
| Field::ExifMeteringMode
| Field::ExifSceneType
| Field::ExifContrast
| Field::ExifSaturation
| Field::ExifSharpness
| Field::ExifBodySerial
| Field::ExifLensSerial
| Field::ExifUserComment
| Field::ExifImageWidth
| Field::ExifImageHeight
| Field::ExifMaxAperture
| Field::ExifDigitalZoom => exif_handlers::handle_exif_string(ctx, field),
// Content
Field::LineCount => content_handlers::handle_line_count(ctx),
Field::WordCount => content_handlers::handle_word_count(ctx),
Field::CharCount => content_handlers::handle_char_count(ctx),
Field::Encoding => content_handlers::handle_encoding(ctx),
Field::HasBom => content_handlers::handle_has_bom(ctx),
Field::LineEnding => content_handlers::handle_line_ending(ctx),
Field::Mime => content_handlers::handle_mime(ctx),
Field::IsBinary | Field::IsText => content_handlers::handle_is_binary_or_text(ctx, field),
Field::IsArchive
| Field::IsAudio
| Field::IsBook
| Field::IsDoc
| Field::IsFont
| Field::IsImage
| Field::IsSource
| Field::IsVideo => content_handlers::handle_is_type(ctx, field),
// Hashes
Field::Sha1 => hash_handlers::handle_sha1(ctx),
Field::Sha256 => hash_handlers::handle_sha256(ctx),
Field::Sha512 => hash_handlers::handle_sha512(ctx),
Field::Sha3 => hash_handlers::handle_sha3(ctx),
}
}
+95
View File
@@ -0,0 +1,95 @@
use crate::field::Field;
use crate::field::context::FieldContext;
use crate::util::*;
use crate::util::datetime::parse_datetime;
use crate::util::error::SearchError;
pub fn handle_exif_datetime(ctx: &mut FieldContext, field: &Field) -> Result<Variant, SearchError> {
ctx.fms.update_exif_metadata(ctx.entry);
let key = match field {
Field::ExifDateTime => "DateTime",
Field::ExifDateTimeOriginal => "DateTimeOriginal",
_ => return Err(SearchError::fatal(format!("Unexpected field in handle_exif_datetime: {:?}", field))),
};
if let Some(exif_info) = ctx.fms.get_exif_metadata()
&& let Some(exif_value) = exif_info.get(key)
&& let Ok(exif_datetime) = parse_datetime(exif_value) {
return Ok(Variant::from_datetime(exif_datetime.0));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_exif_gps_altitude(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_exif_metadata(ctx.entry);
if let Some(exif_info) = ctx.fms.get_exif_metadata()
&& let Some(exif_value) = exif_info.get("__Alt")
&& let Ok(value) = exif_value.parse() {
return Ok(Variant::from_float(value));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_exif_gps_latitude(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_exif_metadata(ctx.entry);
if let Some(exif_info) = ctx.fms.get_exif_metadata()
&& let Some(exif_value) = exif_info.get("__Lat")
&& let Ok(value) = exif_value.parse() {
return Ok(Variant::from_float(value));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_exif_gps_longitude(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_exif_metadata(ctx.entry);
if let Some(exif_info) = ctx.fms.get_exif_metadata()
&& let Some(exif_value) = exif_info.get("__Lng")
&& let Ok(value) = exif_value.parse() {
return Ok(Variant::from_float(value));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_exif_string(ctx: &mut FieldContext, field: &Field) -> Result<Variant, SearchError> {
let key = match field {
Field::ExifMake => "Make",
Field::ExifModel => "Model",
Field::ExifSoftware => "Software",
Field::ExifVersion => "ExifVersion",
Field::ExifExposureTime => "ExposureTime",
Field::ExifAperture => "ApertureValue",
Field::ExifShutterSpeed => "ShutterSpeedValue",
Field::ExifFNumber => "FNumber",
Field::ExifIsoSpeed => "ISOSpeed",
Field::ExifPhotographicSensitivity => "PhotographicSensitivity",
Field::ExifFocalLength => "FocalLength",
Field::ExifLensMake => "LensMake",
Field::ExifLensModel => "LensModel",
Field::ExifDescription => "ImageDescription",
Field::ExifArtist => "Artist",
Field::ExifCopyright => "Copyright",
Field::ExifOrientation => "Orientation",
Field::ExifFlash => "Flash",
Field::ExifColorSpace => "ColorSpace",
Field::ExifExposureProgram => "ExposureProgram",
Field::ExifExposureBias => "ExposureBiasValue",
Field::ExifWhiteBalance => "WhiteBalance",
Field::ExifMeteringMode => "MeteringMode",
Field::ExifSceneType => "SceneCaptureType",
Field::ExifContrast => "Contrast",
Field::ExifSaturation => "Saturation",
Field::ExifSharpness => "Sharpness",
Field::ExifBodySerial => "BodySerialNumber",
Field::ExifLensSerial => "LensSerialNumber",
Field::ExifUserComment => "UserComment",
Field::ExifImageWidth => "PixelXDimension",
Field::ExifImageHeight => "PixelYDimension",
Field::ExifMaxAperture => "MaxApertureValue",
Field::ExifDigitalZoom => "DigitalZoomRatio",
_ => return Err(SearchError::fatal(format!("Unexpected field in handle_exif_string: {:?}", field))),
};
if let Some(val) = ctx.fms.get_exif_string(ctx.entry, key) {
return Ok(val);
}
Ok(Variant::empty(VariantType::String))
}
+77
View File
@@ -0,0 +1,77 @@
//! Handlers for git-related fields.
use crate::field::context::FieldContext;
use crate::util::*;
use crate::util::error::SearchError;
#[cfg(feature = "git")]
use crate::util::git::status_to_string;
pub fn handle_is_git_repo(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(Variant::from_bool(ctx.entry.path().join(".git").exists()))
}
#[cfg(feature = "git")]
pub fn handle_is_git_tracked(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let path = ctx.entry.path();
Ok(Variant::from_bool(
ctx.git_cache.is_tracked(&path).unwrap_or(false),
))
}
#[cfg(feature = "git")]
pub fn handle_is_gitignored(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let path = ctx.entry.path();
Ok(Variant::from_bool(
ctx.git_cache.is_ignored(&path).unwrap_or(false),
))
}
#[cfg(feature = "git")]
pub fn handle_git_status(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let path = ctx.entry.path();
match ctx.git_cache.status(&path) {
Some(status) => Ok(Variant::from_string(&status_to_string(status).to_string())),
None => Ok(Variant::empty(VariantType::String)),
}
}
#[cfg(feature = "git")]
pub fn handle_git_branch(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let path = ctx.entry.path();
match ctx.git_cache.branch(&path) {
Some(branch) => Ok(Variant::from_string(&branch)),
None => Ok(Variant::empty(VariantType::String)),
}
}
#[cfg(feature = "git")]
pub fn handle_git_last_commit_hash(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let path = ctx.entry.path();
match ctx.git_cache.last_commit(&path) {
Some(commit) => Ok(Variant::from_string(&commit.hash)),
None => Ok(Variant::empty(VariantType::String)),
}
}
#[cfg(feature = "git")]
pub fn handle_git_last_commit_date(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let path = ctx.entry.path();
if let Some(commit) = ctx.git_cache.last_commit(&path)
&& commit.time >= 0
&& let Some(naive) = system_time_to_naive_local(
std::time::UNIX_EPOCH + std::time::Duration::from_secs(commit.time as u64),
) {
return Ok(Variant::from_datetime(naive));
}
Ok(Variant::empty(VariantType::String))
}
#[cfg(feature = "git")]
pub fn handle_git_last_commit_author(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let path = ctx.entry.path();
match ctx.git_cache.last_commit(&path) {
Some(commit) => Ok(Variant::from_string(&commit.author)),
None => Ok(Variant::empty(VariantType::String)),
}
}
+23
View File
@@ -0,0 +1,23 @@
use crate::field::context::FieldContext;
use crate::util::error::SearchError;
use crate::util::Variant;
pub fn handle_sha1(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let hash = ctx.fms.get_or_compute_sha1(ctx.entry).to_string();
Ok(Variant::from_string(&hash))
}
pub fn handle_sha256(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let hash = ctx.fms.get_or_compute_sha256(ctx.entry).to_string();
Ok(Variant::from_string(&hash))
}
pub fn handle_sha512(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let hash = ctx.fms.get_or_compute_sha512(ctx.entry).to_string();
Ok(Variant::from_string(&hash))
}
pub fn handle_sha3(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let hash = ctx.fms.get_or_compute_sha3(ctx.entry).to_string();
Ok(Variant::from_string(&hash))
}
+93
View File
@@ -0,0 +1,93 @@
use crate::field::context::FieldContext;
use crate::util::*;
use crate::util::error::SearchError;
pub fn handle_width(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_dimensions(ctx.entry);
if let Some(&Dimensions { width, .. }) = ctx.fms.get_dimensions() {
return Ok(Variant::from_int(width as i64));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_height(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_dimensions(ctx.entry);
if let Some(&Dimensions { height, .. }) = ctx.fms.get_dimensions() {
return Ok(Variant::from_int(height as i64));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_duration(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_duration(ctx.entry);
if let Some(&Duration { length, .. }) = ctx.fms.get_duration() {
return Ok(Variant::from_int(length as i64));
}
Ok(Variant::empty(VariantType::String))
}
/// Read a numeric audio property (bitrate, sample rate) into a variant.
fn audio_int(
ctx: &mut FieldContext,
select: impl Fn(&AudioInfo) -> Option<u32>,
) -> Result<Variant, SearchError> {
ctx.fms.update_audio_info(ctx.entry);
if let Some(info) = ctx.fms.get_audio_info()
&& let Some(value) = select(info) {
return Ok(Variant::from_int(value as i64));
}
Ok(Variant::empty(VariantType::String))
}
/// Read a string audio tag (title, artist, ...) into a variant.
fn audio_string(
ctx: &mut FieldContext,
select: impl Fn(&AudioInfo) -> Option<&String>,
) -> Result<Variant, SearchError> {
ctx.fms.update_audio_info(ctx.entry);
if let Some(info) = ctx.fms.get_audio_info()
&& let Some(value) = select(info) {
return Ok(Variant::from_string(value));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_bitrate(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_int(ctx, |info| info.bitrate)
}
pub fn handle_freq(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_int(ctx, |info| info.sample_rate)
}
pub fn handle_year(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_int(ctx, |info| info.year)
}
pub fn handle_title(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_string(ctx, |info| info.title.as_ref())
}
pub fn handle_artist(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_string(ctx, |info| info.artist.as_ref())
}
pub fn handle_album(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_string(ctx, |info| info.album.as_ref())
}
pub fn handle_genre(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_string(ctx, |info| info.genre.as_ref())
}
pub fn handle_comment(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_string(ctx, |info| info.comment.as_ref())
}
pub fn handle_track(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_string(ctx, |info| info.track.as_ref())
}
pub fn handle_disc(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
audio_string(ctx, |info| info.disc.as_ref())
}
+824
View File
@@ -0,0 +1,824 @@
#![allow(unused_imports, unused_variables)]
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(unix)]
use xattr::FileExt;
use crate::field::context::FieldContext;
use crate::mode;
use crate::util::*;
use crate::util::error::SearchError;
/// Defines a handler that reads a single integer value from the entry's Unix
/// metadata. The accessor (e.g. `ino`, `dev`) is only referenced on Unix, so the
/// whole metadata read is gated and other platforms fall back to an empty value.
macro_rules! unix_int_handler {
($name:ident, $accessor:ident, $empty:expr) => {
pub fn $name(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(unix)]
{
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
return Ok(Variant::from_int(attrs.$accessor() as i64));
}
}
Ok(Variant::empty($empty))
}
};
}
/// Defines the paired `<time>`/`<time>_nsec` handlers. On Unix they read the
/// raw `st_*` fields; elsewhere they fall back to the portable `SystemTime`
/// accessor so the fields aren't just empty on Windows. Note `ctime` maps to
/// creation time there (the Windows CRT's own st_ctime convention) — Unix
/// keeps real inode-change time, which `created()` would not be.
macro_rules! epoch_time_handler {
($name:ident, $nsec_name:ident, $unix_accessor:ident, $unix_nsec_accessor:ident, $st_accessor:ident) => {
pub fn $name(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
#[cfg(unix)]
return Ok(Variant::from_int(attrs.$unix_accessor() as i64));
#[cfg(not(unix))]
if let Ok(sdt) = attrs.$st_accessor()
&& let Ok(duration) = sdt.duration_since(std::time::UNIX_EPOCH)
{
return Ok(Variant::from_int(duration.as_secs() as i64));
}
}
Ok(Variant::empty(VariantType::Int))
}
pub fn $nsec_name(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
#[cfg(unix)]
return Ok(Variant::from_int(attrs.$unix_nsec_accessor() as i64));
#[cfg(not(unix))]
if let Ok(sdt) = attrs.$st_accessor()
&& let Ok(duration) = sdt.duration_since(std::time::UNIX_EPOCH)
{
return Ok(Variant::from_int(duration.subsec_nanos() as i64));
}
}
Ok(Variant::empty(VariantType::Int))
}
};
}
/// Defines a handler that reads a `SystemTime` from the entry's metadata
/// (`created`/`accessed`) and converts it to a local datetime variant.
macro_rules! datetime_handler {
($name:ident, $accessor:ident) => {
pub fn $name(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
if let Ok(sdt) = attrs.$accessor() {
if let Some(naive) = system_time_to_naive_local(sdt) {
return Ok(Variant::from_datetime(naive));
}
}
}
Ok(Variant::empty(VariantType::String))
}
};
}
/// Opens the entry and reads the named extended attribute, flattening an absent
/// attribute and any I/O error into `None`. Linux-only.
#[cfg(target_os = "linux")]
fn read_xattr(entry: &fs::DirEntry, name: &str) -> Option<Vec<u8>> {
fs::File::open(entry.path())
.ok()
.and_then(|file| file.get_xattr(name).ok().flatten())
}
pub fn handle_size(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_int(file_info.size as i64))
}
_ => {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
return Ok(Variant::from_int(attrs.len() as i64));
}
Ok(Variant::empty(VariantType::String))
}
}
}
pub fn handle_formatted_size(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_string(&format_filesize(
file_info.size,
ctx.config
.default_file_size_format
.as_ref()
.unwrap_or(&String::new()),
)?))
}
_ => {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
return Ok(Variant::from_string(&format_filesize(
attrs.len(),
ctx.config
.default_file_size_format
.as_ref()
.unwrap_or(&String::new()),
)?));
}
Ok(Variant::empty(VariantType::String))
}
}
}
pub fn handle_is_dir(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_bool(
file_info.name.ends_with('/') || file_info.name.ends_with('\\'),
))
}
_ => Ok(file_type_predicate(ctx, |m| m.is_dir(), |ft| ft.is_dir())),
}
}
pub fn handle_is_file(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_bool(!file_info.name.ends_with('/') && !file_info.name.ends_with('\\')))
}
_ => Ok(file_type_predicate(ctx, |m| m.is_file(), |ft| ft.is_file())),
}
}
pub fn handle_is_symlink(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(_) => {
Ok(Variant::from_bool(false))
}
_ => {
// `is_symlink` never follows the link, so the entry's own file
// type answers it directly — reusing the type resolved during
// traversal when available, and typically needing no syscall even
// when computed here, unlike the symlink_metadata() fallback.
if let Some(file_type) = ctx.fms.get_or_compute_file_type(ctx.entry) {
return Ok(Variant::from_bool(file_type.is_symlink()));
}
if let Some(meta) = get_metadata(ctx.entry, false) {
return Ok(Variant::from_bool(meta.file_type().is_symlink()));
}
Ok(Variant::empty(VariantType::String))
}
}
}
/// Whether the entry itself is a symlink, resolved without following the link
/// and reusing the file type computed during traversal when available, so this
/// usually costs no syscall. A failure to determine the type is reported as
/// `false`.
fn entry_is_symlink(ctx: &mut FieldContext) -> bool {
if let Some(file_type) = ctx.fms.get_or_compute_file_type(ctx.entry) {
return file_type.is_symlink();
}
get_metadata(ctx.entry, false)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(false)
}
pub fn handle_link_target(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
// Archived entries carry no link target.
Some(_) => Ok(Variant::empty(VariantType::String)),
_ => {
if entry_is_symlink(ctx)
&& let Ok(target) = fs::read_link(ctx.entry.path()) {
return Ok(Variant::from_string(&target.to_string_lossy().to_string()));
}
Ok(Variant::empty(VariantType::String))
}
}
}
pub fn handle_is_broken_symlink(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(_) => Ok(Variant::from_bool(false)),
_ => {
if !entry_is_symlink(ctx) {
return Ok(Variant::from_bool(false));
}
// The entry is a symlink; it is broken when its target cannot be
// resolved. `fs::metadata` follows the link, so an error means the
// target is missing (or otherwise unreachable).
Ok(Variant::from_bool(fs::metadata(ctx.entry.path()).is_err()))
}
}
}
/// Resolve a file-type predicate (is_dir/is_file) with the fewest syscalls:
/// reuse already-loaded metadata, otherwise use the directory entry's file
/// type (carried over from reading the directory, so usually free) when not
/// following symlinks, and only stat as a last resort. The entry's file type
/// reflects the symlink itself, so it is only valid when we are not following
/// symlinks; otherwise we must follow the link via full metadata.
fn file_type_predicate(
ctx: &mut FieldContext,
from_metadata: impl Fn(&fs::Metadata) -> bool,
from_file_type: impl Fn(fs::FileType) -> bool,
) -> Variant {
if ctx.fms.file_metadata_loaded() {
return match ctx.fms.get_file_metadata() {
Some(attrs) => Variant::from_bool(from_metadata(attrs)),
None => Variant::empty(VariantType::String),
};
}
if !ctx.follow_symlinks
&& let Some(file_type) = ctx.fms.get_or_compute_file_type(ctx.entry) {
return Variant::from_bool(from_file_type(file_type));
}
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
match ctx.fms.get_file_metadata() {
Some(attrs) => Variant::from_bool(from_metadata(attrs)),
None => Variant::empty(VariantType::String),
}
}
pub fn handle_is_pipe(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::is_pipe, &mode::mode_is_pipe))
}
pub fn handle_is_char_device(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::is_char_device, &mode::mode_is_char_device))
}
pub fn handle_is_block_device(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::is_block_device, &mode::mode_is_block_device))
}
pub fn handle_is_socket(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::is_socket, &mode::mode_is_socket))
}
unix_int_handler!(handle_device, dev, VariantType::String);
unix_int_handler!(handle_rdev, rdev, VariantType::String);
unix_int_handler!(handle_inode, ino, VariantType::String);
unix_int_handler!(handle_blocks, blocks, VariantType::String);
unix_int_handler!(handle_blksize, blksize, VariantType::String);
unix_int_handler!(handle_hardlinks, nlink, VariantType::String);
epoch_time_handler!(handle_atime, handle_atime_nsec, atime, atime_nsec, accessed);
epoch_time_handler!(handle_mtime, handle_mtime_nsec, mtime, mtime_nsec, modified);
epoch_time_handler!(handle_ctime, handle_ctime_nsec, ctime, ctime_nsec, created);
datetime_handler!(handle_created, created);
datetime_handler!(handle_accessed, accessed);
pub fn handle_modified(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
if let Some(file_info_modified) = &file_info.modified {
let dt = to_local_datetime(file_info_modified);
return Ok(Variant::from_datetime(dt));
}
Ok(Variant::empty(VariantType::String))
}
_ => {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata()
&& let Ok(sdt) = attrs.modified()
&& let Some(naive) = system_time_to_naive_local(sdt) {
return Ok(Variant::from_datetime(naive));
}
Ok(Variant::empty(VariantType::String))
}
}
}
pub fn handle_is_hidden(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_bool(is_hidden(&file_info.name, &None, true)))
}
_ => {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
Ok(Variant::from_bool(is_hidden(
&ctx.entry.file_name().to_string_lossy(),
ctx.fms.get_file_metadata_as_option(),
false,
)))
}
}
}
pub fn handle_is_empty(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_bool(file_info.size == 0))
}
_ => {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
return match attrs.is_dir() {
true => match is_dir_empty(ctx.entry) {
Some(result) => Ok(Variant::from_bool(result)),
None => Ok(Variant::empty(VariantType::Bool)),
},
false => Ok(Variant::from_bool(attrs.len() == 0)),
};
}
Ok(Variant::empty(VariantType::String))
}
}
}
pub fn handle_has_xattrs(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(unix)]
{
if let Ok(file) = fs::File::open(ctx.entry.path()) {
if let Ok(xattrs) = file.list_xattr() {
let has_xattrs = xattrs.count() > 0;
return Ok(Variant::from_bool(has_xattrs));
}
}
}
#[cfg(windows)]
{
Ok(Variant::from_bool(
crate::util::win_xattr::has_any_ads(&ctx.entry.path()),
))
}
#[cfg(not(windows))]
Ok(Variant::empty(VariantType::Bool))
}
pub fn handle_xattr_count(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(unix)]
{
if let Ok(file) = fs::File::open(ctx.entry.path()) {
if let Ok(xattrs) = file.list_xattr() {
return Ok(Variant::from_int(xattrs.count() as i64));
}
}
}
#[cfg(windows)]
{
Ok(Variant::from_int(
crate::util::win_xattr::count_ads(&ctx.entry.path()) as i64,
))
}
#[cfg(not(windows))]
Ok(Variant::empty(VariantType::Int))
}
pub fn handle_extattrs(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(target_os = "linux")]
{
if let Ok(file) = fs::File::open(ctx.entry.path()) {
if let Some(flags) = crate::util::extattrs::get_ext_attrs(&file) {
return Ok(Variant::from_string(
&crate::util::extattrs::format_ext_attrs(flags),
));
}
}
}
#[cfg(windows)]
{
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(meta) = ctx.fms.get_file_metadata() {
let attrs = crate::util::win_attrs::get_attrs(meta);
return Ok(Variant::from_string(
&crate::util::win_attrs::format_attrs(attrs),
));
}
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_has_extattrs(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(target_os = "linux")]
{
if let Ok(file) = fs::File::open(ctx.entry.path()) {
if let Some(flags) = crate::util::extattrs::get_ext_attrs(&file) {
return Ok(Variant::from_bool(flags != 0));
}
}
}
#[cfg(windows)]
{
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(meta) = ctx.fms.get_file_metadata() {
let attrs = crate::util::win_attrs::get_attrs(meta);
return Ok(Variant::from_bool(crate::util::win_attrs::has_any_attr(attrs)));
}
}
Ok(Variant::empty(VariantType::Bool))
}
pub fn handle_acl(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(target_os = "linux")]
{
if let Some(acl_data) = read_xattr(ctx.entry, "system.posix_acl_access") {
if let Some(entries) = crate::util::acl::parse_acl(&acl_data) {
return Ok(Variant::from_string(&crate::util::acl::format_acl(&entries)));
}
}
}
#[cfg(windows)]
{
if let Some(acl_str) = crate::util::win_acl::format_acl(&ctx.entry.path()) {
return Ok(Variant::from_string(&acl_str));
}
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_has_acl(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(target_os = "linux")]
{
if let Some(acl_data) = read_xattr(ctx.entry, "system.posix_acl_access") {
if let Some(entries) = crate::util::acl::parse_acl(&acl_data) {
return Ok(Variant::from_bool(!entries.is_empty()));
}
}
}
#[cfg(windows)]
{
Ok(Variant::from_bool(
crate::util::win_acl::has_explicit_acl(&ctx.entry.path()),
))
}
#[cfg(not(windows))]
Ok(Variant::empty(VariantType::Bool))
}
pub fn handle_default_acl(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(target_os = "linux")]
{
if ctx.entry.path().is_dir() {
if let Some(acl_data) = read_xattr(ctx.entry, "system.posix_acl_default") {
if let Some(entries) = crate::util::acl::parse_acl(&acl_data) {
return Ok(Variant::from_string(&crate::util::acl::format_acl(&entries)));
}
}
}
}
#[cfg(windows)]
{
if ctx.entry.path().is_dir()
&& let Some(acl_str) = crate::util::win_acl::format_default_acl(&ctx.entry.path()) {
return Ok(Variant::from_string(&acl_str));
}
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_has_default_acl(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(target_os = "linux")]
{
if ctx.entry.path().is_dir() {
if let Some(acl_data) = read_xattr(ctx.entry, "system.posix_acl_default") {
if let Some(entries) = crate::util::acl::parse_acl(&acl_data) {
return Ok(Variant::from_bool(!entries.is_empty()));
}
}
}
}
#[cfg(windows)]
{
if ctx.entry.path().is_dir() {
return Ok(Variant::from_bool(
crate::util::win_acl::has_default_acl(&ctx.entry.path()),
));
}
}
Ok(Variant::empty(VariantType::Bool))
}
pub fn handle_has_capabilities(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(target_os = "linux")]
{
if let Ok(file) = fs::File::open(ctx.entry.path()) {
if let Ok(caps_xattr) = file.get_xattr("security.capability") {
return Ok(Variant::from_bool(caps_xattr.is_some()));
}
}
}
Ok(Variant::empty(VariantType::Bool))
}
pub fn handle_capabilities(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
#[cfg(target_os = "linux")]
{
if let Some(caps_xattr) = read_xattr(ctx.entry, "security.capability") {
let caps_string = crate::util::capabilities::parse_capabilities(caps_xattr);
return Ok(Variant::from_string(&caps_string));
}
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_is_shebang(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(Variant::from_bool(is_shebang(&ctx.entry.path())))
}
pub fn check_file_mode(
ctx: &mut FieldContext,
mode_func_boxed: &dyn Fn(&std::fs::Metadata) -> bool,
mode_func_i32: &dyn Fn(u32) -> bool,
) -> Variant {
match ctx.file_info {
Some(file_info) => {
if let Some(mode) = file_info.mode {
return Variant::from_bool(mode_func_i32(mode));
}
}
_ => {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
return Variant::from_bool(mode_func_boxed(attrs));
}
}
}
Variant::from_bool(false)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use crate::config::Config;
use crate::field::Field;
use crate::field::context::{FieldContext, FileMetadataState};
use crate::field::dispatch;
use crate::fileinfo::FileInfo;
fn test_field(
entry: &fs::DirEntry,
file_info: &Option<FileInfo>,
root_path: &Path,
field: &Field,
) -> crate::util::Variant {
let config = Config::default();
let default_config = Config::default();
let mut fms = FileMetadataState::new();
#[cfg(feature = "git")]
let mut git_cache = crate::util::git::GitCache::new();
#[cfg(all(unix, feature = "users"))]
let user_cache = uzers::UsersCache::new();
let mut ctx = FieldContext {
entry,
file_info,
root_path,
fms: &mut fms,
#[cfg(feature = "git")]
git_cache: &mut git_cache,
follow_symlinks: true,
config: &config,
default_config: &default_config,
#[cfg(all(unix, feature = "users"))]
user_cache: &user_cache,
};
dispatch::get_field_value(&mut ctx, field).unwrap()
}
#[test]
fn test_is_file_false_for_backslash_terminated_archive_entry() {
let tmp = std::env::temp_dir().join("fselect_test_isfile_backslash_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("dummy.txt"), "").unwrap();
let entry = fs::read_dir(&tmp).unwrap().next().unwrap().unwrap();
let file_info = Some(FileInfo {
name: String::from("somedir\\"),
size: 0,
mode: None,
modified: None,
});
let result = test_field(&entry, &file_info, &tmp, &Field::IsFile);
let _ = fs::remove_dir_all(&tmp);
assert_eq!(result.to_string(), "false");
}
#[test]
fn test_is_dir_and_is_file_consistent_for_backslash_archive_entry() {
let tmp = std::env::temp_dir().join("fselect_test_consistency_backslash_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("dummy.txt"), "").unwrap();
let entry = fs::read_dir(&tmp).unwrap().next().unwrap().unwrap();
let file_info = Some(FileInfo {
name: String::from("somedir\\"),
size: 0,
mode: None,
modified: None,
});
let is_dir = test_field(&entry, &file_info, &tmp, &Field::IsDir);
let is_file = test_field(&entry, &file_info, &tmp, &Field::IsFile);
let _ = fs::remove_dir_all(&tmp);
assert_eq!(is_dir.to_string(), "true");
assert_eq!(is_file.to_string(), "false");
}
#[test]
fn test_is_symlink_true_when_following_symlinks() {
let tmp = std::env::temp_dir().join("fselect_test_symlink_follow_islink_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("real_file.txt"), "hello world").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("real_file.txt", tmp.join("link")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(tmp.join("real_file.txt"), tmp.join("link")).unwrap();
let entry = fs::read_dir(&tmp)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name() == "link")
.unwrap();
let result = test_field(&entry, &None, &tmp, &Field::IsSymlink);
let _ = fs::remove_dir_all(&tmp);
assert_eq!(
result.to_string(),
"true",
"is_symlink should be true for a symlink even when follow_symlinks is on"
);
}
#[test]
fn test_link_target_returns_target_for_symlink() {
let tmp = std::env::temp_dir().join("fselect_test_link_target_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("real_file.txt"), "hello world").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("real_file.txt", tmp.join("link")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(tmp.join("real_file.txt"), tmp.join("link")).unwrap();
let entry = fs::read_dir(&tmp)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name() == "link")
.unwrap();
let result = test_field(&entry, &None, &tmp, &Field::LinkTarget);
let _ = fs::remove_dir_all(&tmp);
assert!(
result.to_string().contains("real_file.txt"),
"link_target should point at the symlink's target, got {}",
result.to_string()
);
}
#[test]
fn test_link_target_empty_for_regular_file() {
let tmp = std::env::temp_dir().join("fselect_test_link_target_regular_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("real_file.txt"), "hello world").unwrap();
let entry = fs::read_dir(&tmp)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name() == "real_file.txt")
.unwrap();
let result = test_field(&entry, &None, &tmp, &Field::LinkTarget);
let _ = fs::remove_dir_all(&tmp);
assert_eq!(result.to_string(), "", "link_target should be empty for a non-symlink");
}
#[test]
fn test_is_broken_symlink_true_for_dangling_link() {
let tmp = std::env::temp_dir().join("fselect_test_broken_symlink_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
// Point at a target that does not exist.
#[cfg(unix)]
std::os::unix::fs::symlink("does_not_exist.txt", tmp.join("link")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(tmp.join("does_not_exist.txt"), tmp.join("link")).unwrap();
let entry = fs::read_dir(&tmp)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name() == "link")
.unwrap();
let result = test_field(&entry, &None, &tmp, &Field::IsBrokenSymlink);
let _ = fs::remove_dir_all(&tmp);
assert_eq!(result.to_string(), "true", "is_broken_symlink should be true for a dangling link");
}
#[test]
fn test_is_broken_symlink_false_for_valid_link() {
let tmp = std::env::temp_dir().join("fselect_test_valid_symlink_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("real_file.txt"), "hello world").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("real_file.txt", tmp.join("link")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(tmp.join("real_file.txt"), tmp.join("link")).unwrap();
let entry = fs::read_dir(&tmp)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name() == "link")
.unwrap();
let result = test_field(&entry, &None, &tmp, &Field::IsBrokenSymlink);
let _ = fs::remove_dir_all(&tmp);
assert_eq!(result.to_string(), "false", "is_broken_symlink should be false for a working link");
}
#[test]
fn test_is_broken_symlink_false_for_regular_file() {
let tmp = std::env::temp_dir().join("fselect_test_broken_symlink_regular_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("real_file.txt"), "hello world").unwrap();
let entry = fs::read_dir(&tmp)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name() == "real_file.txt")
.unwrap();
let result = test_field(&entry, &None, &tmp, &Field::IsBrokenSymlink);
let _ = fs::remove_dir_all(&tmp);
assert_eq!(result.to_string(), "false", "is_broken_symlink should be false for a non-symlink");
}
#[test]
fn test_size_follows_symlink_when_requested() {
let tmp = std::env::temp_dir().join("fselect_test_symlink_follow_size_h");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
let content = "hello world, this is a reasonably long test string for size comparison";
fs::write(tmp.join("real_file.txt"), content).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("real_file.txt", tmp.join("link")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(tmp.join("real_file.txt"), tmp.join("link")).unwrap();
let entry = fs::read_dir(&tmp)
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name() == "link")
.unwrap();
let result = test_field(&entry, &None, &tmp, &Field::Size);
let _ = fs::remove_dir_all(&tmp);
let size = result.to_int();
let expected_size = content.len() as i64;
assert_eq!(
size, expected_size,
"size should be target file's size ({}) when following symlinks, got {}",
expected_size, size
);
}
}
+1009
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
use crate::field::context::FieldContext;
use crate::field::metadata_handlers::check_file_mode;
use crate::mode;
use crate::util::*;
use crate::util::error::SearchError;
pub fn handle_mode(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
if let Some(mode) = file_info.mode {
return Ok(Variant::from_string(&mode::format_unix_mode(mode)));
}
Ok(Variant::empty(VariantType::String))
}
_ => {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
return Ok(Variant::from_string(&mode::get_mode(attrs)));
}
Ok(Variant::empty(VariantType::String))
}
}
}
pub fn handle_user_read(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::user_read, &mode::mode_user_read))
}
pub fn handle_user_write(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::user_write, &mode::mode_user_write))
}
pub fn handle_user_exec(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::user_exec, &mode::mode_user_exec))
}
pub fn handle_user_all(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::user_all, &mode::mode_user_all))
}
pub fn handle_group_read(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::group_read, &mode::mode_group_read))
}
pub fn handle_group_write(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::group_write, &mode::mode_group_write))
}
pub fn handle_group_exec(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::group_exec, &mode::mode_group_exec))
}
pub fn handle_group_all(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::group_all, &mode::mode_group_all))
}
pub fn handle_other_read(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::other_read, &mode::mode_other_read))
}
pub fn handle_other_write(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::other_write, &mode::mode_other_write))
}
pub fn handle_other_exec(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::other_exec, &mode::mode_other_exec))
}
pub fn handle_other_all(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::other_all, &mode::mode_other_all))
}
pub fn handle_suid(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::suid_bit_set, &mode::mode_suid))
}
pub fn handle_sgid(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::sgid_bit_set, &mode::mode_sgid))
}
pub fn handle_is_sticky(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
Ok(check_file_mode(ctx, &mode::sticky_bit_set, &mode::mode_sticky))
}
pub fn handle_uid(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata()
&& let Some(uid) = mode::get_uid(attrs) {
return Ok(Variant::from_int(uid as i64));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_gid(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata()
&& let Some(gid) = mode::get_gid(attrs) {
return Ok(Variant::from_int(gid as i64));
}
Ok(Variant::empty(VariantType::String))
}
#[cfg(all(unix, feature = "users"))]
pub fn handle_user(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
use uzers::Users;
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
if let Some(uid) = mode::get_uid(attrs) {
if let Some(user) = ctx.user_cache.get_user_by_uid(uid) {
return Ok(Variant::from_string(
&user.name().to_string_lossy().to_string(),
));
}
}
}
Ok(Variant::empty(VariantType::String))
}
#[cfg(all(unix, feature = "users"))]
pub fn handle_group(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
use uzers::Groups;
ctx.fms.update_file_metadata(ctx.entry, ctx.follow_symlinks);
if let Some(attrs) = ctx.fms.get_file_metadata() {
if let Some(gid) = mode::get_gid(attrs) {
if let Some(group) = ctx.user_cache.get_group_by_gid(gid) {
return Ok(Variant::from_string(
&group.name().to_string_lossy().to_string(),
));
}
}
}
Ok(Variant::empty(VariantType::String))
}
+117
View File
@@ -0,0 +1,117 @@
use std::path::{Path, PathBuf};
use crate::field::context::FieldContext;
use crate::util::*;
use crate::util::error::SearchError;
pub fn handle_name(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
let name = Path::new(&file_info.name)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| file_info.name.clone());
Ok(Variant::from_string(&name))
}
_ => {
Ok(Variant::from_string(&ctx.entry.file_name().to_string_lossy().to_string()))
}
}
}
pub fn handle_filename(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_string(&get_stem(&file_info.name)))
}
_ => {
Ok(Variant::from_string(
&get_stem(&ctx.entry.file_name().to_string_lossy())
.to_string(),
))
}
}
}
pub fn handle_extension(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_string(&get_extension(&file_info.name)))
}
_ => {
Ok(Variant::from_string(
&get_extension(&ctx.entry.file_name().to_string_lossy())
.to_string(),
))
}
}
}
pub fn handle_path(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_string(&file_info.name))
}
_ => {
match ctx.entry.path().strip_prefix(ctx.root_path) {
Ok(stripped_path) => {
Ok(Variant::from_string(&stripped_path.to_string_lossy().to_string()))
}
Err(_) => {
Ok(Variant::from_string(&ctx.entry.path().to_string_lossy().to_string()))
}
}
}
}
}
pub fn handle_abspath(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
match ctx.file_info {
Some(file_info) => {
Ok(Variant::from_string(&file_info.name))
}
_ => {
match canonical_path(&ctx.entry.path()) {
Ok(path) => {
Ok(Variant::from_string(&path))
},
Err(e) => {
Err(format!("could not get absolute path: {}", e).into())
}
}
}
}
}
pub fn handle_directory(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let file_path = match ctx.file_info {
Some(file_info) => file_info.name.clone(),
_ => match ctx.entry.path().strip_prefix(ctx.root_path) {
Ok(relative_path) => relative_path.to_string_lossy().to_string(),
Err(_) => ctx.entry.path().to_string_lossy().to_string()
},
};
let pb = PathBuf::from(file_path);
if let Some(parent) = pb.parent() {
return Ok(Variant::from_string(&parent.to_string_lossy().to_string()));
}
Ok(Variant::empty(VariantType::String))
}
pub fn handle_absdir(ctx: &mut FieldContext) -> Result<Variant, SearchError> {
let file_path = match ctx.file_info {
Some(file_info) => file_info.name.clone(),
_ => ctx.entry.path().to_string_lossy().to_string(),
};
let pb = PathBuf::from(file_path);
if let Some(parent) = pb.parent() {
if ctx.file_info.is_some() {
return Ok(Variant::from_string(&parent.to_string_lossy().to_string()));
}
if let Ok(path) = canonical_path(&parent.to_path_buf()) {
return Ok(Variant::from_string(&path));
}
}
Ok(Variant::empty(VariantType::String))
}
+20
View File
@@ -0,0 +1,20 @@
use zip::DateTime;
pub struct FileInfo {
pub name: String,
pub size: u64,
pub mode: Option<u32>,
pub modified: Option<DateTime>,
}
pub fn to_file_info<R>(zipped_file: &zip::read::ZipFile<R>) -> FileInfo
where
R: std::io::Read + std::io::Seek
{
FileInfo {
name: zipped_file.name().to_string(),
size: zipped_file.size(),
mode: zipped_file.unix_mode(),
modified: zipped_file.last_modified(),
}
}
+3953
View File
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
//! Handles .dockerignore parsing
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::ops::Add;
use std::path::Path;
use regex::Regex;
#[derive(Clone, Debug)]
pub struct DockerignoreFilter {
pub regex: Regex,
pub negate: bool,
}
impl DockerignoreFilter {
fn new(regex: Regex, negate: bool) -> DockerignoreFilter {
DockerignoreFilter { regex, negate }
}
}
pub fn search_upstream_dockerignore(
dockerignore_filters: &mut Vec<DockerignoreFilter>,
dir: &Path,
) {
if let Ok(canonical_path) = crate::util::canonical_path(&dir.to_path_buf()) {
let mut path = std::path::PathBuf::from(canonical_path);
loop {
let dockerignore_file = path.join(".dockerignore");
if dockerignore_file.is_file() {
update_dockerignore_filters(dockerignore_filters, &path);
return;
}
let parent_found = path.pop();
if !parent_found {
return;
}
}
}
}
fn update_dockerignore_filters(dockerignore_filters: &mut Vec<DockerignoreFilter>, path: &Path) {
let dockerignore_file = path.join(".dockerignore");
if dockerignore_file.is_file() {
let regexes = parse_dockerignore(&dockerignore_file, path);
match regexes {
Ok(ref regexes) => {
dockerignore_filters.append(&mut regexes.clone());
}
Err(err) => {
eprintln!("{}: {}", path.to_string_lossy(), err);
}
}
}
}
pub fn matches_dockerignore_filter(
dockerignore_filters: &Vec<DockerignoreFilter>,
file_name: &str,
) -> bool {
let mut matched = false;
let file_name = file_name.to_string().replace("\\", "/").replace("//", "/");
for dockerignore_filter in dockerignore_filters {
let is_match = dockerignore_filter.regex.is_match(&file_name);
if is_match {
matched = !dockerignore_filter.negate;
}
}
matched
}
fn parse_dockerignore(
file_path: &Path,
dir_path: &Path,
) -> Result<Vec<DockerignoreFilter>, String> {
let mut result = vec![];
let mut err = String::new();
if let Ok(file) = File::open(file_path) {
let reader = BufReader::new(file);
for (index, line) in reader.lines().enumerate() {
if err.is_empty()
&& let Ok(line) = line {
// Docker strips a UTF-8 BOM from the first line and
// trims whitespace from every line before parsing.
let line = match index {
0 => line.trim_start_matches('\u{feff}'),
_ => line.as_str(),
};
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let pattern = convert_dockerignore_pattern(line, dir_path);
match pattern {
Ok(pattern) => result.push(pattern),
Err(parse_err) => err = parse_err,
}
}
}
};
match err.is_empty() {
true => Ok(result),
false => Err(err),
}
}
fn convert_dockerignore_pattern(
pattern: &str,
file_path: &Path,
) -> Result<DockerignoreFilter, String> {
let mut pattern = pattern;
let mut negate = false;
if let Some(rest) = pattern.strip_prefix('!') {
// Docker trims whitespace again after removing the `!`.
pattern = rest.trim_start();
negate = true;
}
match convert_dockerignore_glob(pattern, file_path) {
Ok(regex) => Ok(DockerignoreFilter::new(regex, negate)),
_ => Err("Error creating regex while parsing .dockerignore glob: "
.to_string()
.add(pattern)),
}
}
// Finds the index of the `]` closing the character class that starts at
// `start` (which must hold `[`), honoring `\` escapes; None if unterminated.
fn find_char_class_end(chars: &[char], start: usize) -> Option<usize> {
let mut i = start + 1;
while i < chars.len() {
match chars[i] {
'\\' => i += 2,
']' => return Some(i),
_ => i += 1,
}
}
None
}
fn convert_dockerignore_glob(glob: &str, file_path: &Path) -> Result<Regex, String> {
// Patterns are relative to the context root; leading and trailing
// separators carry no meaning.
let glob_trimmed = glob.trim_start_matches(['/', '\\']).trim_end_matches('/');
if glob_trimmed.is_empty() {
return Err("Error parsing .dockerignore pattern: ".to_string() + glob);
}
let mut pattern = String::new();
let chars: Vec<char> = glob_trimmed.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'*' if chars.get(i + 1) == Some(&'*') => {
// `**/` matches any number of leading directories,
// including none.
if chars.get(i + 2) == Some(&'/') {
pattern.push_str("(?:.*/)?");
i += 3;
} else {
pattern.push_str(".*");
i += 2;
}
}
'*' => {
pattern.push_str("[^/]*");
i += 1;
}
'?' => {
pattern.push_str("[^/]");
i += 1;
}
'[' => match find_char_class_end(&chars, i) {
// Go filepath.Match character classes (`[abc]`, ranges like
// `[a-z]`, negated `[^abc]`) pass through as regex classes,
// exactly as Docker's patternmatcher does.
Some(end) => {
pattern.extend(&chars[i..=end]);
i = end + 1;
}
// An unterminated `[` is ErrBadPattern in Go (never
// matches); escaping it as a literal is the safer choice
// here since an invalid pattern would otherwise discard
// the whole .dockerignore file.
None => {
pattern.push_str("\\[");
i += 1;
}
},
c @ ('.' | ']' | '(' | ')' | '^' | '$' | '+' | '{' | '}' | '|' | '\\') => {
pattern.push('\\');
pattern.push(c);
i += 1;
}
c => {
pattern.push(c);
i += 1;
}
}
}
#[cfg(windows)]
let path = file_path
.to_string_lossy()
.to_string()
.replace("\\", "/")
.replace("//", "/");
#[cfg(not(windows))]
let path = file_path.to_string_lossy().to_string();
// Docker patterns are anchored at the context root (the directory holding
// the .dockerignore) rather than floating to any depth, and a pattern that
// matches a directory also excludes everything beneath it.
let pattern = String::from("^")
.add(&regex::escape(&path))
.add("/")
.add(&pattern)
.add("(?:/.*)?$");
Regex::new(&pattern).map_err(|_| "Error creating regex pattern: ".to_string() + pattern.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negate_pattern_removes_only_leading_exclamation() {
let filter = convert_dockerignore_pattern("!foo!bar", Path::new("/tmp")).unwrap();
assert!(filter.negate);
let regex_str = filter.regex.as_str();
assert!(
regex_str.contains("foo!bar") || regex_str.contains("foo\\!bar"),
"pattern should preserve non-leading ! but got: {}",
regex_str
);
}
#[test]
fn last_matching_rule_wins() {
let filters = vec![
DockerignoreFilter::new(Regex::new(".*\\.log$").unwrap(), false),
DockerignoreFilter::new(Regex::new(".*important\\.log$").unwrap(), true),
DockerignoreFilter::new(Regex::new(".*\\.log$").unwrap(), false),
];
assert!(
matches_dockerignore_filter(&filters, "important.log"),
"last non-negated *.log should override the negation"
);
}
#[test]
fn path_with_dots_is_regex_escaped() {
let result = convert_dockerignore_glob("*.txt", Path::new("/home/user/my.project"));
let regex = result.unwrap();
let regex_str = regex.as_str();
assert!(
regex_str.contains("my\\.project"),
"dots in path should be escaped but got: {}",
regex_str
);
}
#[test]
fn char_class_matches_listed_characters() {
let filter = convert_dockerignore_pattern("*.[ch]", Path::new("/ctx")).unwrap();
assert!(filter.regex.is_match("/ctx/main.c"));
assert!(filter.regex.is_match("/ctx/util.h"));
assert!(!filter.regex.is_match("/ctx/main.o"));
}
#[test]
fn char_class_supports_ranges() {
let filter = convert_dockerignore_pattern("file[a-c].txt", Path::new("/ctx")).unwrap();
assert!(filter.regex.is_match("/ctx/filea.txt"));
assert!(filter.regex.is_match("/ctx/filec.txt"));
assert!(!filter.regex.is_match("/ctx/filed.txt"));
}
#[test]
fn char_class_supports_negation() {
let filter = convert_dockerignore_pattern("file[^a].txt", Path::new("/ctx")).unwrap();
assert!(filter.regex.is_match("/ctx/fileb.txt"));
assert!(!filter.regex.is_match("/ctx/filea.txt"));
}
#[test]
fn unterminated_char_class_is_treated_as_literal() {
let filter = convert_dockerignore_pattern("foo[bar", Path::new("/ctx")).unwrap();
assert!(filter.regex.is_match("/ctx/foo[bar"));
assert!(!filter.regex.is_match("/ctx/foobar"));
}
#[test]
fn glob_with_plus_is_escaped() {
let result = convert_dockerignore_glob("a+b.txt", Path::new("/tmp"));
assert!(result.is_ok());
let regex = result.unwrap();
let regex_str = regex.as_str();
assert!(
regex_str.contains("\\+"),
"plus should be escaped but got: {}",
regex_str
);
}
#[test]
fn pattern_matches_whole_component_not_prefix() {
let filter = convert_dockerignore_pattern("foo", Path::new("/ctx")).unwrap();
assert!(filter.regex.is_match("/ctx/foo"));
assert!(
filter.regex.is_match("/ctx/foo/sub/file.txt"),
"a matched directory excludes its subtree"
);
assert!(
!filter.regex.is_match("/ctx/foobar"),
"pattern must not match by prefix"
);
}
#[test]
fn pattern_is_anchored_to_context_root() {
let filter = convert_dockerignore_pattern("foo", Path::new("/ctx")).unwrap();
assert!(
!filter.regex.is_match("/ctx/a/foo"),
"plain patterns are root-relative in Docker, not any-depth"
);
assert!(
!filter.regex.is_match("/other/ctx/foo"),
"pattern must not match under a different root"
);
}
#[test]
fn doublestar_matches_any_depth_including_root() {
let filter = convert_dockerignore_pattern("**/foo", Path::new("/ctx")).unwrap();
assert!(filter.regex.is_match("/ctx/foo"), "`**/` includes zero directories");
assert!(filter.regex.is_match("/ctx/a/b/foo"));
assert!(!filter.regex.is_match("/ctx/a/foobar"));
}
#[test]
fn star_does_not_cross_separators() {
let filter = convert_dockerignore_pattern("*.md", Path::new("/ctx")).unwrap();
assert!(filter.regex.is_match("/ctx/readme.md"));
assert!(
!filter.regex.is_match("/ctx/sub/readme.md"),
"`*.md` matches only at the context root in Docker"
);
}
#[test]
fn trailing_slash_matches_directory_and_contents() {
let filter = convert_dockerignore_pattern("build/", Path::new("/ctx")).unwrap();
assert!(filter.regex.is_match("/ctx/build"));
assert!(filter.regex.is_match("/ctx/build/out/app.bin"));
assert!(!filter.regex.is_match("/ctx/builder"));
}
#[test]
fn test_all_slashes_pattern_rejected() {
let result = convert_dockerignore_glob("///", Path::new("/tmp"));
assert!(result.is_err(), "pattern of only slashes should be rejected");
}
#[test]
fn indented_comment_is_ignored() {
use std::io::Write;
let dir = std::env::temp_dir().join("fselect_docker_indented_comment_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file_path = dir.join(".dockerignore");
{
let mut f = std::fs::File::create(&file_path).unwrap();
writeln!(f, " # this is an indented comment").unwrap();
writeln!(f, "real_pattern").unwrap();
}
let filters = parse_dockerignore(&file_path, &dir).unwrap();
assert_eq!(
filters.len(),
1,
"indented comment should not be parsed as a pattern, got {} filters",
filters.len()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lines_are_whitespace_trimmed() {
let dir = std::env::temp_dir().join("fselect_docker_trim_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file_path = dir.join(".dockerignore");
std::fs::write(&file_path, "foo \n !bar\n").unwrap();
let filters = parse_dockerignore(&file_path, &dir).unwrap();
assert_eq!(filters.len(), 2);
let target_foo = dir.join("foo").to_string_lossy().to_string();
assert!(
matches_dockerignore_filter(&filters, &target_foo),
"trailing whitespace should be trimmed before compilation"
);
assert!(filters[1].negate, "indented negation should be detected");
let target_bar = dir.join("bar").to_string_lossy().replace('\\', "/");
assert!(
filters[1].regex.is_match(&target_bar),
"leading whitespace should be trimmed before compilation"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn utf8_bom_is_stripped_from_first_line() {
let dir = std::env::temp_dir().join("fselect_docker_bom_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file_path = dir.join(".dockerignore");
std::fs::write(&file_path, b"\xEF\xBB\xBFfoo\n").unwrap();
let filters = parse_dockerignore(&file_path, &dir).unwrap();
assert_eq!(filters.len(), 1);
let target = dir.join("foo").to_string_lossy().to_string();
assert!(
matches_dockerignore_filter(&filters, &target),
"first pattern should match despite the BOM"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
+713
View File
@@ -0,0 +1,713 @@
//! Handles .hgignore parsing (Mercurial)
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::ops::Add;
use std::ops::Index;
use std::path::Path;
use std::sync::LazyLock;
use regex::Captures;
use regex::Regex;
#[derive(Clone, Debug)]
pub struct HgignoreFilter {
pub regex: Regex,
}
impl HgignoreFilter {
fn new(regex: Regex) -> HgignoreFilter {
HgignoreFilter { regex }
}
}
pub fn search_upstream_hgignore(hgignore_filters: &mut Vec<HgignoreFilter>, dir: &Path) {
if let Ok(canonical_path) = crate::util::canonical_path(&dir.to_path_buf()) {
let mut path = std::path::PathBuf::from(canonical_path);
loop {
let hgignore_file = path.join(".hgignore");
let hg_directory = path.join(".hg");
if hgignore_file.is_file() && hg_directory.is_dir() {
update_hgignore_filters(hgignore_filters, &path);
return;
}
let parent_found = path.pop();
if !parent_found {
return;
}
}
}
}
fn update_hgignore_filters(hgignore_filters: &mut Vec<HgignoreFilter>, path: &Path) {
let hgignore_file = path.join(".hgignore");
if hgignore_file.is_file() {
let mut regexes = parse_hgignore(&hgignore_file, path);
match regexes {
Ok(ref mut regexes) => {
hgignore_filters.append(regexes);
}
Err(err) => {
eprintln!("{}: {}", path.to_string_lossy(), err);
}
}
}
}
pub fn matches_hgignore_filter(hgignore_filters: &[HgignoreFilter], file_name: &str) -> bool {
hgignore_filters
.iter()
.any(|filter| filter.regex.is_match(file_name))
}
enum Syntax {
Regexp,
Glob,
RootGlob,
}
impl Syntax {
fn from(s: &str) -> Option<Syntax> {
match s {
"regexp" | "re" => Some(Syntax::Regexp),
"glob" => Some(Syntax::Glob),
"rootglob" => Some(Syntax::RootGlob),
_ => None,
}
}
}
// Mercurial's readpatternfile: a `#` preceded by an even number of
// backslashes starts a comment; `\#` is an escaped literal hash.
static HG_COMMENT_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new("((?:^|[^\\\\])(?:\\\\\\\\)*)#").unwrap());
fn strip_hg_comment(line: &str) -> String {
if line.contains('#') {
let mut line = line.to_string();
if let Some(caps) = HG_COMMENT_REGEX.captures(&line) {
line.truncate(caps.get(1).unwrap().end());
}
line.replace("\\#", "#")
} else {
line.to_string()
}
}
fn parse_hgignore(file_path: &Path, dir_path: &Path) -> Result<Vec<HgignoreFilter>, String> {
let mut result = vec![];
let mut err = String::new();
if let Ok(file) = File::open(file_path) {
let mut syntax = Syntax::Regexp;
let reader = BufReader::new(file);
for line in reader.lines() {
if err.is_empty()
&& let Ok(line) = line {
let line = strip_hg_comment(&line);
// Mercurial rstrip()s each line (but keeps leading
// whitespace, which makes a directive an ordinary
// pattern).
let line = line.trim_end();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("syntax:") {
let syntax_directive = rest.trim();
match Syntax::from(syntax_directive) {
Some(parsed_syntax) => syntax = parsed_syntax,
// An unknown syntax only skips this line;
// Mercurial warns and keeps the previous
// syntax in effect.
None => eprintln!(
"{}: ignoring invalid syntax '{}'",
file_path.to_string_lossy(),
syntax_directive
),
}
} else if let Some(rest) = line.strip_prefix("subinclude:") {
let include = rest.trim();
#[cfg(windows)]
let include = include.replace('/', "\\");
// Relative paths resolve against the directory of
// the file containing the subinclude, and the
// subincluded patterns are rooted at the subinclude
// file's directory (Mercurial `subinclude:`).
let include_path = match file_path.parent() {
Some(parent) => parent.join(&include),
None => Path::new(&include).to_path_buf(),
};
let sub_dir_path = include_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| dir_path.to_path_buf());
let mut parse_result = parse_hgignore(&include_path, &sub_dir_path);
match parse_result {
Ok(ref mut filters) => {
result.append(filters);
}
Err(parse_err) => {
err = parse_err;
}
};
} else {
let pattern = convert_hgignore_pattern(line, dir_path, &syntax);
match pattern {
Ok(pattern) => result.push(pattern),
Err(parse_err) => err = parse_err,
}
}
}
}
};
match err.is_empty() {
true => Ok(result),
false => Err(err),
}
}
fn convert_hgignore_pattern(
pattern: &str,
file_path: &Path,
syntax: &Syntax,
) -> Result<HgignoreFilter, String> {
match syntax {
Syntax::Glob => match convert_hgignore_glob(pattern, file_path, false) {
Ok(regex) => Ok(HgignoreFilter::new(regex)),
Err(e) => Err(e),
},
Syntax::RootGlob => match convert_hgignore_glob(pattern, file_path, true) {
Ok(regex) => Ok(HgignoreFilter::new(regex)),
Err(e) => Err(e),
},
Syntax::Regexp => match convert_hgignore_regexp(pattern, file_path) {
Ok(regex) => Ok(HgignoreFilter::new(regex)),
Err(e) => Err(e),
},
}
}
static HG_CONVERT_REPLACE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new("(\\*\\*|\\?|\\.|\\[|\\]|\\(|\\)|\\^|\\$|\\*|\\+|\\{|\\}|\\||\\\\|/)").unwrap()
});
fn convert_hgignore_glob(glob: &str, file_path: &Path, rooted: bool) -> Result<Regex, String> {
#[cfg(not(windows))]
{
let mut pattern = HG_CONVERT_REPLACE_REGEX
.replace_all(&glob, |c: &Captures| {
match c.index(0) {
"**" => ".*",
"." => "\\.",
"*" => "[^/]*",
"?" => "[^/]",
"[" => "\\[",
"]" => "\\]",
"(" => "\\(",
")" => "\\)",
"^" => "\\^",
"$" => "\\$",
"+" => "\\+",
"{" => "\\{",
"}" => "\\}",
"|" => "\\|",
"\\" => "\\\\",
"/" => "/",
_ => "",
}
.to_string()
})
.to_string();
if pattern.is_empty() {
return Err("Error parsing .hgignore pattern: ".to_string() + glob);
}
// `**/` matches any number of leading directories, including none
// (the `.*` token can only originate from `**`).
pattern = pattern.replace(".*/", "(?:.*/)?");
// Glob patterns are unrooted (they match at any directory level)
// while `rootglob` patterns anchor at the repo root; both must cover
// whole path components: like Mercurial itself, a match ends at a
// separator or the end of the path, so `foo` matches `foo` and
// `foo/bar` but not `foobar`.
pattern = String::from("^")
.add(&regex::escape(&file_path.to_string_lossy()))
.add(if rooted { "/" } else { "/([^/]+/)*" })
.add(&pattern)
.add("(?:/|$)");
Regex::new(&pattern).map_err(|_| "Error creating regex pattern: ".to_string() + pattern.as_str())
}
#[cfg(windows)]
{
let mut pattern = HG_CONVERT_REPLACE_REGEX
.replace_all(glob, |c: &Captures| {
match c.index(0) {
"**" => ".*",
"." => "\\.",
"*" => "[^\\\\]*",
"?" => "[^\\\\]",
"[" => "\\[",
"]" => "\\]",
"(" => "\\(",
")" => "\\)",
"^" => "\\^",
"$" => "\\$",
"+" => "\\+",
"{" => "\\{",
"}" => "\\}",
"|" => "\\|",
"\\" => "\\\\",
// Mercurial patterns always use forward slashes; paths
// are matched with native separators on Windows.
"/" => "\\\\",
_ => "",
}
.to_string()
})
.to_string();
if pattern.is_empty() {
return Err("Error parsing .hgignore pattern: ".to_string() + glob);
}
// `**/` matches any number of leading directories, including none
// (the `.*` token can only originate from `**`).
pattern = pattern.replace(".*\\\\", "(?:.*\\\\)?");
// See the Unix branch: unrooted (or root-anchored for `rootglob`),
// but matches whole path components.
pattern = String::from("^")
.add(&regex::escape(&file_path.to_string_lossy()))
.add(if rooted { "\\\\" } else { "\\\\([^\\\\]+\\\\)*" })
.add(&pattern)
.add("(?:\\\\|$)");
Regex::new(&pattern).map_err(|_| "Error creating regex pattern: ".to_string() + pattern.as_str())
}
}
fn convert_hgignore_regexp(regexp: &str, file_path: &Path) -> Result<Regex, String> {
// Mercurial matches regexp patterns with `re.search` against the
// repo-relative path: unanchored unless the pattern starts with `^`.
// Only the repository-root prefix is anchored here; no end anchor is
// added so the user's regex keeps full control.
#[cfg(not(windows))]
{
let mut pattern = String::from("^") + &regex::escape(&file_path.to_string_lossy());
if !regexp.starts_with("^") {
pattern = pattern.add("/([^/]+/)*");
pattern = pattern.add(".*");
} else {
pattern = pattern.add("/");
}
pattern = pattern.add(&regexp.trim_start_matches("^"));
Regex::new(&pattern).map_err(|_| "Error creating regex pattern: ".to_string() + pattern.as_str())
}
#[cfg(windows)]
{
// Mercurial regexps use `/` as the path separator while matched
// paths are native `\`-separated on Windows, so an (optionally
// escaped) `/` in the pattern must match a literal backslash.
let regexp = regexp.replace("\\/", "/").replace('/', "\\\\");
let mut pattern = String::from("^") + &regex::escape(&file_path.to_string_lossy());
if !regexp.starts_with("^") {
pattern = pattern.add("\\\\([^\\\\]+\\\\)*");
pattern = pattern.add(".*");
} else {
pattern = pattern.add("\\\\");
}
pattern = pattern.add(regexp.trim_start_matches("^"));
Regex::new(&pattern).map_err(|_| "Error creating regex pattern: ".to_string() + pattern.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(windows))]
#[test]
fn glob_question_mark_matches_exactly_one_char() {
let regex = convert_hgignore_glob("a?b", Path::new("/tmp"), false).unwrap();
assert!(regex.is_match("/tmp/axb"), "? should match single char");
assert!(
!regex.is_match("/tmp/axxb"),
"? should not match two chars but got match"
);
}
#[cfg(not(windows))]
#[test]
fn glob_path_with_dots_is_regex_escaped() {
let result = convert_hgignore_glob("*.txt", Path::new("/home/user/my.project"), false);
let regex = result.unwrap();
let regex_str = regex.as_str();
assert!(
regex_str.contains("my\\.project"),
"dots in path should be escaped but got: {}",
regex_str
);
}
#[cfg(not(windows))]
#[test]
fn regexp_path_with_dots_is_regex_escaped() {
let result = convert_hgignore_regexp("foo", Path::new("/home/user/my.project"));
let regex = result.unwrap();
let regex_str = regex.as_str();
assert!(
regex_str.contains("my\\.project"),
"dots in path should be escaped but got: {}",
regex_str
);
}
#[cfg(not(windows))]
#[test]
fn glob_brackets_are_escaped() {
let result = convert_hgignore_glob("[test]", Path::new("/tmp"), false);
let regex = result.unwrap();
let regex_str = regex.as_str();
assert!(
regex_str.contains("\\[") && regex_str.contains("\\]"),
"brackets should be escaped but got: {}",
regex_str
);
}
#[cfg(windows)]
#[test]
fn glob_question_mark_matches_exactly_one_char_windows() {
let regex = convert_hgignore_glob("a?b", Path::new("C:\\tmp"), false).unwrap();
assert!(regex.is_match("C:\\tmp\\axb"), "? should match single char");
assert!(
!regex.is_match("C:\\tmp\\axxb"),
"? should not match two chars but got match"
);
}
#[cfg(windows)]
#[test]
fn glob_path_with_dots_is_regex_escaped_windows() {
let result = convert_hgignore_glob("*.txt", Path::new("C:\\Users\\user\\my.project"), false);
let regex = result.unwrap();
let regex_str = regex.as_str();
assert!(
regex_str.contains("my\\.project"),
"dots in path should be escaped but got: {}",
regex_str
);
}
#[cfg(windows)]
#[test]
fn regexp_path_with_dots_is_regex_escaped_windows() {
let result = convert_hgignore_regexp("foo", Path::new("C:\\Users\\user\\my.project"));
let regex = result.unwrap();
let regex_str = regex.as_str();
assert!(
regex_str.contains("my\\.project"),
"dots in path should be escaped but got: {}",
regex_str
);
}
#[cfg(windows)]
#[test]
fn glob_brackets_are_escaped_windows() {
let result = convert_hgignore_glob("[test]", Path::new("C:\\tmp"), false);
let regex = result.unwrap();
let regex_str = regex.as_str();
assert!(
regex_str.contains("\\[") && regex_str.contains("\\]"),
"brackets should be escaped but got: {}",
regex_str
);
}
#[cfg(not(windows))]
#[test]
fn glob_plus_and_pipe_are_escaped() {
let regex = convert_hgignore_glob("c++", Path::new("/repo"), false).unwrap();
assert!(regex.is_match("/repo/c++"), "+ should be literal");
assert!(!regex.is_match("/repo/ccc"), "+ should not repeat");
let regex = convert_hgignore_glob("a|b*", Path::new("/repo"), false).unwrap();
assert!(regex.is_match("/repo/a|bc"), "| should be literal");
assert!(!regex.is_match("/repo/ab.txt"), "| should not alternate");
}
#[cfg(not(windows))]
#[test]
fn glob_double_star_slash_matches_zero_dirs() {
let regex = convert_hgignore_glob("**/foo", Path::new("/repo"), false).unwrap();
assert!(regex.is_match("/repo/foo"), "**/ should match zero dirs");
assert!(regex.is_match("/repo/a/b/foo"), "**/ should match many dirs");
}
#[cfg(windows)]
#[test]
fn glob_plus_and_pipe_are_escaped_windows() {
let regex = convert_hgignore_glob("c++", Path::new("C:\\repo"), false).unwrap();
assert!(regex.is_match("C:\\repo\\c++"), "+ should be literal");
assert!(!regex.is_match("C:\\repo\\ccc"), "+ should not repeat");
let regex = convert_hgignore_glob("a|b*", Path::new("C:\\repo"), false).unwrap();
assert!(!regex.is_match("C:\\repo\\ab.txt"), "| should not alternate");
}
#[cfg(windows)]
#[test]
fn glob_double_star_slash_matches_zero_dirs_windows() {
let regex = convert_hgignore_glob("**/foo", Path::new("C:\\repo"), false).unwrap();
assert!(regex.is_match("C:\\repo\\foo"), "**/ should match zero dirs");
assert!(regex.is_match("C:\\repo\\a\\b\\foo"), "**/ should match many dirs");
}
#[cfg(not(windows))]
#[test]
fn regexp_caret_anchored_includes_separator() {
let regex = convert_hgignore_regexp("^src/main", Path::new("/repo")).unwrap();
assert!(regex.is_match("/repo/src/main.rs"), "^-anchored pattern should match");
assert!(!regex.is_match("/reposrc/main.rs"), "should not match without separator");
}
#[cfg(windows)]
#[test]
fn regexp_caret_anchored_includes_separator_windows() {
let regex = convert_hgignore_regexp("^src/main", Path::new("C:\\repo")).unwrap();
assert!(
regex.is_match("C:\\repo\\src\\main.rs"),
"^-anchored pattern with / should match a native path"
);
assert!(!regex.is_match("C:\\repo\\srcmain.rs"), "should not match without separator");
}
#[cfg(windows)]
#[test]
fn regexp_slash_matches_native_separator_windows() {
let regex = convert_hgignore_regexp("target/debug", Path::new("C:\\repo")).unwrap();
assert!(
regex.is_match("C:\\repo\\x\\target\\debug\\foo.o"),
"unanchored regexp with / should match a native path"
);
let regex = convert_hgignore_regexp("^src\\/main", Path::new("C:\\repo")).unwrap();
assert!(
regex.is_match("C:\\repo\\src\\main.rs"),
"escaped \\/ should also match the native separator"
);
}
#[cfg(not(windows))]
#[test]
fn glob_matches_whole_component_not_prefix() {
let regex = convert_hgignore_glob("foo", Path::new("/repo"), false).unwrap();
assert!(regex.is_match("/repo/foo"));
assert!(regex.is_match("/repo/sub/foo"), "hg globs are unrooted");
assert!(
regex.is_match("/repo/foo/inner.txt"),
"a matched directory covers its contents"
);
assert!(!regex.is_match("/repo/foobar"), "must not match by prefix");
}
#[cfg(not(windows))]
#[test]
fn glob_repo_prefix_is_start_anchored() {
let regex = convert_hgignore_glob("foo", Path::new("/repo"), false).unwrap();
assert!(
!regex.is_match("/elsewhere/repo/foo"),
"repo prefix must match from the start of the path"
);
}
#[cfg(not(windows))]
#[test]
fn regexp_repo_prefix_is_start_anchored() {
let regex = convert_hgignore_regexp("foo", Path::new("/repo")).unwrap();
assert!(regex.is_match("/repo/x/myfoo.txt"), "re.search semantics within the repo");
assert!(!regex.is_match("/elsewhere/repo/x/foo"));
}
#[cfg(windows)]
#[test]
fn glob_matches_whole_component_not_prefix_windows() {
let regex = convert_hgignore_glob("foo", Path::new("C:\\repo"), false).unwrap();
assert!(regex.is_match("C:\\repo\\foo"));
assert!(regex.is_match("C:\\repo\\sub\\foo"), "hg globs are unrooted");
assert!(
regex.is_match("C:\\repo\\foo\\inner.txt"),
"a matched directory covers its contents"
);
assert!(!regex.is_match("C:\\repo\\foobar"), "must not match by prefix");
}
#[cfg(windows)]
#[test]
fn glob_repo_prefix_is_start_anchored_windows() {
let regex = convert_hgignore_glob("foo", Path::new("C:\\repo"), false).unwrap();
assert!(
!regex.is_match("X:\\zzzC:\\repo\\foo"),
"repo prefix must match from the start of the path"
);
}
#[cfg(windows)]
#[test]
fn regexp_repo_prefix_is_start_anchored_windows() {
let regex = convert_hgignore_regexp("foo", Path::new("C:\\repo")).unwrap();
assert!(regex.is_match("C:\\repo\\x\\myfoo.txt"), "re.search semantics within the repo");
assert!(!regex.is_match("X:\\zzzC:\\repo\\x\\foo"));
}
#[test]
fn matches_hgignore_filter_returns_true_when_any_matches() {
let filters = vec![
HgignoreFilter::new(Regex::new("never_matches_xyz").unwrap()),
HgignoreFilter::new(Regex::new("foo").unwrap()),
HgignoreFilter::new(Regex::new("also_never_xyz").unwrap()),
];
assert!(matches_hgignore_filter(&filters, "some/foo/path"));
}
#[test]
fn matches_hgignore_filter_returns_false_when_none_match() {
let filters = vec![
HgignoreFilter::new(Regex::new("never_a").unwrap()),
HgignoreFilter::new(Regex::new("never_b").unwrap()),
];
assert!(!matches_hgignore_filter(&filters, "some/path"));
}
fn make_test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn inline_comment_is_stripped() {
let dir = make_test_dir("fselect_hg_inline_comment_test");
let file_path = dir.join(".hgignore");
std::fs::write(&file_path, "syntax: glob\n*.log # build logs\n").unwrap();
let filters = parse_hgignore(&file_path, &dir).unwrap();
assert_eq!(filters.len(), 1);
let target = dir.join("x.log");
assert!(
matches_hgignore_filter(&filters, &target.to_string_lossy()),
"inline comment and trailing whitespace should be stripped"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn escaped_hash_is_literal() {
let dir = make_test_dir("fselect_hg_escaped_hash_test");
let file_path = dir.join(".hgignore");
std::fs::write(&file_path, "syntax: glob\nfoo\\#bar\n").unwrap();
let filters = parse_hgignore(&file_path, &dir).unwrap();
assert_eq!(filters.len(), 1);
let target = dir.join("foo#bar");
assert!(
matches_hgignore_filter(&filters, &target.to_string_lossy()),
"\\# should be an escaped literal hash, not a comment"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unknown_syntax_directive_skips_line_only() {
let dir = make_test_dir("fselect_hg_unknown_syntax_test");
let file_path = dir.join(".hgignore");
std::fs::write(&file_path, "syntax: glob\n*.log\nsyntax: bogus\n*.tmp\n").unwrap();
let filters = parse_hgignore(&file_path, &dir)
.expect("an unknown syntax directive should not fail the whole file");
assert_eq!(filters.len(), 2, "patterns after the bad directive should survive");
assert!(matches_hgignore_filter(
&filters,
&dir.join("x.log").to_string_lossy()
));
assert!(
matches_hgignore_filter(&filters, &dir.join("x.tmp").to_string_lossy()),
"the previous syntax should stay in effect after the bad directive"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rootglob_is_root_anchored() {
let dir = make_test_dir("fselect_hg_rootglob_test");
let file_path = dir.join(".hgignore");
std::fs::write(&file_path, "syntax: rootglob\nbuild\n").unwrap();
let filters = parse_hgignore(&file_path, &dir).unwrap();
assert_eq!(filters.len(), 1);
assert!(matches_hgignore_filter(
&filters,
&dir.join("build").to_string_lossy()
));
assert!(
matches_hgignore_filter(&filters, &dir.join("build").join("o.txt").to_string_lossy()),
"a matched directory covers its contents"
);
assert!(
!matches_hgignore_filter(&filters, &dir.join("sub").join("build").to_string_lossy()),
"rootglob patterns are anchored at the repo root"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn subinclude_resolves_relative_to_including_file() {
let dir = make_test_dir("fselect_hg_subinclude_test");
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let file_path = dir.join(".hgignore");
std::fs::write(&file_path, "subinclude:sub/.hgignore\n").unwrap();
std::fs::write(sub.join(".hgignore"), "syntax: glob\n*.o\n").unwrap();
let filters = parse_hgignore(&file_path, &dir).unwrap();
assert_eq!(
filters.len(),
1,
"the include path should resolve against the including file's directory, not the CWD"
);
assert!(matches_hgignore_filter(
&filters,
&sub.join("a.o").to_string_lossy()
));
assert!(
!matches_hgignore_filter(&filters, &dir.join("a.o").to_string_lossy()),
"subincluded patterns are rooted at the subinclude file's directory"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
+2
View File
@@ -0,0 +1,2 @@
pub(crate) mod docker;
pub(crate) mod hg;
+3135
View File
File diff suppressed because it is too large Load Diff
+675
View File
@@ -0,0 +1,675 @@
//! The entry point of the program
//! Handles the command line arguments parsing
#[macro_use]
extern crate serde_derive;
#[cfg(all(unix, feature = "users"))]
extern crate uzers;
#[cfg(unix)]
extern crate xattr;
use std::{env, fs};
use std::io::{stderr, stdout, IsTerminal};
use std::path::PathBuf;
use std::process::ExitCode;
#[cfg(feature = "update-notifications")]
use std::time::Duration;
use nu_ansi_term::Color::*;
#[cfg(feature = "interactive")]
use rustyline::error::ReadlineError;
#[cfg(feature = "interactive")]
use rustyline::DefaultEditor;
#[cfg(feature = "update-notifications")]
use update_informer::{registry, Check};
use crate::config::Config;
use crate::field::Field;
use crate::function::Function;
use crate::lexer::Lexer;
use crate::output::OutputFormat;
use crate::parser::Parser;
use crate::query::RootOptions;
use crate::searcher::Searcher;
use crate::util::{set_us_dates, str_to_bool};
use crate::util::error::{error_message, get_no_errors, set_no_errors, set_use_colors};
mod config;
mod expr;
mod field;
mod fileinfo;
mod function;
mod ignore;
mod lexer;
mod mode;
mod operators;
mod output;
mod parser;
mod query;
mod searcher;
mod util;
fn main() -> ExitCode {
let default_config = Config::default();
let mut config = Config::new().unwrap_or_else(|err| {
eprintln!("{}", err);
Config::default()
});
let env_no_color = std::env::var("NO_COLOR").is_ok();
let mut no_color = env_no_color || config.no_color.unwrap_or(false) || !stdout().is_terminal();
#[cfg(windows)]
{
if !no_color {
let res = nu_ansi_term::enable_ansi_support();
let win_init_ok = matches!(res, Ok(()) | Err(203));
no_color = !win_init_ok;
}
}
if env::args().len() == 1 {
short_usage_info(no_color);
help_hint();
return ExitCode::SUCCESS;
}
let mut args: Vec<String> = env::args().collect();
args.remove(0);
let mut first_arg = args[0].to_ascii_lowercase();
// Flags are matched exactly: a quoted query is a single argument and may
// well contain words like "version" or "help" (e.g. "exif_version from .").
if matches!(first_arg.as_str(), "version" | "-v" | "--version" | "/version") {
short_usage_info(no_color);
return ExitCode::SUCCESS;
}
if matches!(first_arg.as_str(), "help" | "-h" | "--help" | "-help" | "/?" | "/h" | "/help") {
usage_info(config, default_config, no_color);
return ExitCode::SUCCESS;
}
if first_arg.starts_with("--fields") {
complete_fields_info();
return ExitCode::SUCCESS;
}
if first_arg.starts_with("--functions") {
complete_functions_info();
return ExitCode::SUCCESS;
}
if first_arg.starts_with("--root-options") {
complete_root_options_info();
return ExitCode::SUCCESS;
}
if first_arg.starts_with("--output-formats") {
complete_output_formats_info();
return ExitCode::SUCCESS;
}
#[allow(unused_mut)]
let mut interactive = false;
loop {
if matches!(first_arg.as_str(), "--nocolor" | "--no-color" | "-nocolor" | "/nocolor") {
no_color = true;
} else if matches!(first_arg.as_str(), "-i" | "--interactive" | "/i") {
#[cfg(feature = "interactive")]
{ interactive = true; }
} else if matches!(first_arg.as_str(), "-c" | "--config" | "/c" | "/config") {
if args.len() < 2 {
eprintln!("Error: --config requires a path argument");
return ExitCode::from(2);
}
let config_path = args[1].clone();
config = Config::from(PathBuf::from(&config_path)).unwrap_or_else(|err| {
eprintln!("{}", err);
Config::default()
});
args.remove(0);
} else if matches!(first_arg.as_str(), "--no-error" | "--no-errors") {
set_no_errors(true);
} else if matches!(first_arg.as_str(), "--us-date" | "--us-dates") {
set_us_dates(true);
} else if first_arg == "--everything" {
config.everything = Some(true);
} else if first_arg == "--plocate" {
config.plocate = Some(true);
} else {
break;
}
args.remove(0);
if args.is_empty() {
if !interactive {
short_usage_info(no_color);
help_hint();
return ExitCode::SUCCESS;
} else {
break;
}
}
first_arg = args[0].to_ascii_lowercase();
}
// Error messages go to stderr; color them only when stderr is actually a
// terminal, or redirected/piped error output leaks escape codes.
set_use_colors(!no_color && stderr().is_terminal());
if config.us_dates.unwrap_or(default_config.us_dates.unwrap()) {
set_us_dates(true);
}
let mut exit_value = None::<u8>;
#[cfg(feature = "interactive")]
if interactive {
match DefaultEditor::new() {
Ok(mut rl) => {
if let Some(project_dir) = crate::util::app_dirs::get_project_dir() {
let _ = fs::create_dir_all(&project_dir);
let history_file = project_dir.join("history.txt");
let _ = rl.load_history(history_file.as_path());
}
loop {
let readline = rl.readline("query> ");
match readline {
Ok(cmd) => {
let trimmed = cmd.trim().to_ascii_lowercase();
if trimmed == "quit" || trimmed == "exit" {
break;
} else if trimmed == "help" {
usage_info(config.clone(), default_config.clone(), no_color);
} else if trimmed == "pwd" {
match env::current_dir() {
Ok(path) => println!("{}", path.to_string_lossy()),
Err(err) => error_message("pwd", &err.to_string()),
}
} else if trimmed == "cd" || trimmed.starts_with("cd ") {
match extract_cd_path(&cmd) {
None => error_message("cd", "no path specified"),
Some(new_path) => {
let _ = rl.add_history_entry(&cmd);
match env::set_current_dir(new_path) {
Ok(()) => {}
Err(err) => error_message("cd", &err.to_string()),
}
}
}
} else if trimmed == "errors" || trimmed.starts_with("errors ") {
let _ = rl.add_history_entry(&cmd);
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.len() == 2 {
let no_errors = !str_to_bool(parts[1]).unwrap_or(true);
set_no_errors(no_errors);
}
println!("Errors are {}", if no_color {
(if get_no_errors() { "OFF" } else { "ON" }).into()
} else {
Yellow.paint(if get_no_errors() { "OFF" } else { "ON" })
});
} else if trimmed == "debug" || trimmed.starts_with("debug ") {
let _ = rl.add_history_entry(&cmd);
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.len() == 2 {
config.debug = str_to_bool(parts[1]).unwrap_or(false);
}
if no_color {
println!("DEBUG IS {}", (if config.debug { "ON" } else { "OFF" }));
} else {
println!("DEBUG IS {}", Yellow.paint(if config.debug { "ON" } else { "OFF" }));
}
} else {
let _ = rl.add_history_entry(&cmd);
exec_search(vec![cmd], &mut config, &default_config, no_color);
}
}
Err(ReadlineError::Interrupted) => {
println!("CTRL-C");
break;
}
Err(ReadlineError::Eof) => {
println!("CTRL-D");
break;
}
Err(err) => {
let err = format!("{:?}", err);
error_message("input", &err);
break;
}
}
}
if let Some(project_dir) = crate::util::app_dirs::get_project_dir() {
let _ = fs::create_dir_all(&project_dir);
let history_file = project_dir.join("history.txt");
let _ = rl.save_history(history_file.as_path());
}
},
_ => {
error_message("editor", "couldn't open line editor");
exit_value = Some(2);
}
}
} else {
exit_value = Some(exec_search(args, &mut config, &default_config, no_color));
}
#[cfg(not(feature = "interactive"))]
{
exit_value = Some(exec_search(args, &mut config, &default_config, no_color));
}
config.save();
#[cfg(feature = "update-notifications")]
if config.check_for_updates.unwrap_or(false) && stdout().is_terminal() {
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
let informer = update_informer::new(registry::Crates, name, version)
.interval(Duration::from_secs(60 * 60 * 24));
if let Some(version) = informer.check_version().ok().flatten() {
println!("\nNew version is available! : {}", version);
}
}
if let Some(exit_value) = exit_value {
return ExitCode::from(exit_value);
}
ExitCode::SUCCESS
}
fn exec_search(query: Vec<String>, config: &mut Config, default_config: &Config, no_color: bool) -> u8 {
if config.debug {
dbg!(&query);
}
let mut lexer = Lexer::new(query);
let mut parser = Parser::new(&mut lexer);
let query = parser.parse(config.debug);
if config.debug {
dbg!(&query);
}
if parser.there_are_remaining_lexemes() {
error_message("query", "could not parse tokens at the end of the query");
return 2;
}
match query {
Ok(query) => {
let use_colors = !no_color && query.output_format.supports_colorization();
let mut searcher = Searcher::new(&query, config, default_config, use_colors);
let mut abort_code: Option<u8> = None;
if let Err(mut err) = searcher.list_search_results() {
// A consumer that stops reading our output (e.g. piping into
// `head`) ends the search early but is not a failure.
if !err.is_broken_pipe() {
if err.source.is_empty() {
err.source = "result".to_string();
}
err.print();
abort_code = Some(if err.is_fatal() { 2 } else { 1 });
}
}
match abort_code {
Some(code) => code,
None if searcher.error_count > 0 => 1,
None => 0,
}
}
Err(err) => {
error_message("query", &err);
2
}
}
}
fn short_usage_info(no_color: bool) {
const VERSION: &str = env!("CARGO_PKG_VERSION");
print!("fselect ");
if no_color {
println!("{}", VERSION);
} else {
println!("{}", Yellow.paint(VERSION));
}
println!("Find files with SQL-like queries.");
if no_color {
println!("https://github.com/jhspetersson/fselect");
} else {
println!(
"{}",
Cyan.underline()
.paint("https://github.com/jhspetersson/fselect")
);
}
println!();
println!("Usage: fselect [ARGS] COLUMN[, COLUMN...] [from PATH[, PATH...]] [where EXPR] [group by COLUMN, ...] [order by COLUMN (asc|desc), ...] [limit N] [offset N] [into FORMAT]");
}
fn help_hint() {
println!(
"
For more detailed instructions please refer to the URL above or run fselect --help"
);
}
fn usage_info(config: Config, default_config: Config, no_color: bool) {
short_usage_info(no_color);
let is_archive = config
.is_archive
.unwrap_or(default_config.is_archive.unwrap())
.join(", ");
let is_audio = config
.is_audio
.unwrap_or(default_config.is_audio.unwrap())
.join(", ");
let is_book = config
.is_book
.unwrap_or(default_config.is_book.unwrap())
.join(", ");
let is_doc = config
.is_doc
.unwrap_or(default_config.is_doc.unwrap())
.join(", ");
let is_font = config
.is_font
.unwrap_or(default_config.is_font.unwrap())
.join(", ");
let is_image = config
.is_image
.unwrap_or(default_config.is_image.unwrap())
.join(", ");
let is_source = config
.is_source
.unwrap_or(default_config.is_source.unwrap())
.join(", ");
let is_video = config
.is_video
.unwrap_or(default_config.is_video.unwrap())
.join(", ");
println!("
Files Detected as Archives: {is_archive}
Files Detected as Audio: {is_audio}
Files Detected as Book: {is_book}
Files Detected as Document: {is_doc}
Files Detected as Fonts: {is_font}
Files Detected as Image: {is_image}
Files Detected as Source Code: {is_source}
Files Detected as Video: {is_video}
Path Options:
{}
Regex syntax:
{}
Column Options:
{}
Functions:
{}
Expressions:
Operators:
= | == | eq Used to check for equality between the column field and value
=== | eeq Used to check for strict equality between column field and value irregardless of any special regex characters
!= | <> | ne Used to check for inequality between column field and value
!== | ene Used to check for inequality between column field and value irregardless of any special regex characters
< | lt Used to check whether the column value is less than the value
<= | lte | le Used to check whether the column value is less than or equal to the value
> | gt Used to check whether the column value is greater than the value
>= | gte | ge Used to check whether the column value is greater than or equal to the value
~= | =~ | regexp | rx Used to check if the column value matches the regex pattern
!=~ | !~= | notrx Used to check if the column value doesn't match the regex pattern
like Used to check if the column value matches the pattern which follows SQL conventions
notlike Used to check if the column value doesn't match the pattern which follows SQL conventions
between Used to check if the column value lies between two values inclusive
in Used to check if the column value is in the list of values
exists Used to check if there is results in the subquery (optionally bound with the main query)
Logical Operators:
and Used as an AND operator for two conditions made with the above operators
or Used as an OR operator for two conditions made with the above operators
Format:
{}
Interactive mode:
help Get usage help
pwd Print the current working directory
cd PATH Change the current working directory to PATH
errors [ON|OFF] Toggle whether errors are shown or not
exit | quit Exit fselect
", format_root_options(),
if no_color { "https://docs.rs/regex/1.10.2/regex/#syntax".into() } else { Cyan.underline().paint("https://docs.rs/regex/1.10.2/regex/#syntax") },
format_field_usage(),
format_function_usage(),
format_output_usage()
);
}
fn format_root_options() -> String {
RootOptions::get_names_and_descriptions().iter()
.map(|(names, description)| {
let joined = names.join(" | ");
let pad = if 32 > joined.len() { 32 - joined.len() } else { 1 };
joined + &" ".repeat(pad) + description
})
.collect::<Vec<_>>().join("\n ")
}
fn format_field_usage() -> String {
Field::get_names_and_descriptions().iter()
.map(|(names, description)| {
let joined = names.join(" | ");
let pad = if 32 > joined.len() { 32 - joined.len() } else { 1 };
joined + &" ".repeat(pad) + description
})
.collect::<Vec<_>>().join("\n ")
}
fn format_function_usage() -> String {
let funcs = Function::get_names_and_descriptions();
Function::get_groups().iter()
.filter(|group| funcs.contains_key(*group))
.map(|group| {
let funcs_in_group = funcs.get(*group).unwrap();
format!(
"{}:\n {}",
group,
funcs_in_group
.iter()
.map(|(names, description)| {
let joined = names.join(" | ");
let pad = if 28 > joined.len() { 28 - joined.len() } else { 1 };
joined.to_uppercase() + &" ".repeat(pad) + description
})
.collect::<Vec<_>>()
.join("\n ")
)
})
.collect::<Vec<_>>().join("\n\n ")
}
fn format_output_usage() -> String {
OutputFormat::get_names_and_descriptions().iter()
.map(|(name, description)| {
let name = name.to_string();
let pad = if 32 > name.len() { 32 - name.len() } else { 1 };
name + &" ".repeat(pad) + description
})
.collect::<Vec<_>>().join("\n ")
}
fn complete_fields_info() {
println!(
"{}",
Field::get_names_and_descriptions()
.iter()
.map(|(names, _)| names.join(" "))
.collect::<Vec<_>>()
.join(" ")
);
}
fn complete_functions_info() {
println!(
"{}",
Function::get_names_and_descriptions()
.iter()
.flat_map(|entry| entry.1.iter())
.map(|(names, _)| names.join(" ").to_uppercase())
.collect::<Vec<_>>()
.join(" ")
);
}
fn complete_root_options_info() {
println!(
"{}",
RootOptions::get_names_and_descriptions()
.iter()
.map(|(names, _)| names.join(" "))
.collect::<Vec<_>>()
.join(" ")
)
}
#[cfg(feature = "interactive")]
fn extract_cd_path(cmd: &str) -> Option<String> {
let trimmed = cmd.trim();
let rest = trimmed.strip_prefix("cd")?;
// Require at least one whitespace separator after "cd", then take the
// remainder verbatim (preserving internal whitespace in the path).
if !rest.starts_with(char::is_whitespace) {
return None;
}
let path = rest.trim_start();
if path.is_empty() { None } else { Some(path.to_string()) }
}
fn complete_output_formats_info() {
println!(
"{}",
OutputFormat::get_names_and_descriptions()
.iter()
.map(|entry| entry.0.to_string())
.collect::<Vec<_>>()
.join(" ")
)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "interactive")]
use super::extract_cd_path;
use super::exec_search;
use crate::config::Config;
fn run_query(query: &str) -> u8 {
let mut config = Config::default();
let default_config = Config::default();
exec_search(vec![String::from(query)], &mut config, &default_config, true)
}
/// Creates a temp dir with one file and returns its forward-slash path.
fn make_test_dir(name: &str) -> (std::path::PathBuf, String) {
let tmp = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("a.txt"), "x").unwrap();
let path = tmp.to_string_lossy().replace('\\', "/");
(tmp, path)
}
#[test]
fn exec_search_returns_0_on_success() {
let (tmp, path) = make_test_dir("fselect_exit_code_ok");
let code = run_query(&format!("name from {}", path));
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(code, 0);
}
#[test]
fn exec_search_returns_1_on_nonfatal_io_error() {
let (tmp, path) = make_test_dir("fselect_exit_code_io");
let code = run_query(&format!("name from {}/no_such_subdir", path));
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(code, 1);
}
#[test]
fn exec_search_returns_2_on_fatal_search_error() {
// An unknown root alias aborts the search with a fatal error, which
// must surface as a non-zero exit code (previously it exited 0).
let (tmp, path) = make_test_dir("fselect_exit_code_fatal");
let code = run_query(&format!("name, x.name from {}", path));
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(code, 2);
}
#[test]
fn exec_search_returns_2_on_parse_error() {
assert_eq!(run_query(","), 2);
}
#[cfg(feature = "interactive")]
#[test]
fn extract_cd_path_preserves_internal_whitespace() {
// The REPL `cd` parser must keep the path verbatim. Previously it
// split on whitespace then joined with a single space, so a path
// containing multiple spaces was silently collapsed.
assert_eq!(
extract_cd_path("cd /foo bar"),
Some(String::from("/foo bar"))
);
assert_eq!(
extract_cd_path("cd /a/b\tc"),
Some(String::from("/a/b\tc"))
);
assert_eq!(extract_cd_path("cd /tmp"), Some(String::from("/tmp")));
assert_eq!(extract_cd_path("cd"), None);
assert_eq!(extract_cd_path("cdata /tmp"), None);
}
#[test]
fn test_repl_command_matching() {
// Verify that the REPL command matching logic doesn't match partial words
let trimmed = "cdata";
assert!(!(trimmed == "cd" || trimmed.starts_with("cd ")));
let trimmed = "cd /tmp";
assert!(trimmed == "cd" || trimmed.starts_with("cd "));
let trimmed = "errors_table";
assert!(!(trimmed == "errors" || trimmed.starts_with("errors ")));
let trimmed = "debugger";
assert!(!(trimmed == "debug" || trimmed.starts_with("debug ")));
let trimmed = "debug true";
assert!(trimmed == "debug" || trimmed.starts_with("debug "));
}
}
+664
View File
@@ -0,0 +1,664 @@
//! This module contains functions for working with file modes / permissions
use std::fs::Metadata;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
pub fn get_mode(meta: &Metadata) -> String {
#[cfg(unix)]
{
format_mode(meta.mode())
}
#[cfg(windows)]
{
format_mode(meta.file_attributes())
}
#[cfg(not(any(unix, windows)))]
{
let _ = meta;
String::new()
}
}
pub fn format_mode(mode: u32) -> String {
#[cfg(unix)]
{
get_mode_unix(mode)
}
#[cfg(windows)]
{
get_mode_windows(mode)
}
#[cfg(not(any(unix, windows)))]
{
let _ = mode;
String::new()
}
}
/// Format a Unix mode value regardless of platform (for archive entries).
pub fn format_unix_mode(mode: u32) -> String {
let mut s = String::new();
if mode & S_IFMT == 0o120000 {
s.push('l')
} else if mode & S_IFMT == S_IFBLK {
s.push('b')
} else if mode & S_IFMT == S_IFCHR {
s.push('c')
} else if mode & S_IFMT == S_IFSOCK {
s.push('s')
} else if mode & S_IFMT == S_IFIFO {
s.push('p')
} else if mode & S_IFMT == 0o040000 {
s.push('d')
} else {
s.push('-')
}
s.push(if mode_user_read(mode) { 'r' } else { '-' });
s.push(if mode_user_write(mode) { 'w' } else { '-' });
s.push(if mode_user_exec(mode) {
if mode_suid(mode) { 's' } else { 'x' }
} else if mode_suid(mode) { 'S' } else { '-' });
s.push(if mode_group_read(mode) { 'r' } else { '-' });
s.push(if mode_group_write(mode) { 'w' } else { '-' });
s.push(if mode_group_exec(mode) {
if mode_sgid(mode) { 's' } else { 'x' }
} else if mode_sgid(mode) { 'S' } else { '-' });
s.push(if mode_other_read(mode) { 'r' } else { '-' });
s.push(if mode_other_write(mode) { 'w' } else { '-' });
s.push(if mode_other_exec(mode) {
if mode_sticky(mode) { 't' } else { 'x' }
} else if mode_sticky(mode) { 'T' } else { '-' });
s
}
#[cfg(unix)]
fn get_mode_unix(mode: u32) -> String {
let mut s = String::new();
if mode_is_link(mode) {
s.push('l')
} else if mode_is_block_device(mode) {
s.push('b')
} else if mode_is_char_device(mode) {
s.push('c')
} else if mode_is_socket(mode) {
s.push('s')
} else if mode_is_pipe(mode) {
s.push('p')
} else if mode_is_directory(mode) {
s.push('d')
} else {
s.push('-')
}
// user
if mode_user_read(mode) {
s.push('r')
} else {
s.push('-')
}
if mode_user_write(mode) {
s.push('w')
} else {
s.push('-')
}
if mode_user_exec(mode) {
if mode_suid(mode) {
s.push('s')
} else {
s.push('x')
}
} else if mode_suid(mode) {
s.push('S')
} else {
s.push('-')
}
// group
if mode_group_read(mode) {
s.push('r')
} else {
s.push('-')
}
if mode_group_write(mode) {
s.push('w')
} else {
s.push('-')
}
if mode_group_exec(mode) {
if mode_sgid(mode) {
s.push('s')
} else {
s.push('x')
}
} else if mode_sgid(mode) {
s.push('S')
} else {
s.push('-')
}
// other
if mode_other_read(mode) {
s.push('r')
} else {
s.push('-')
}
if mode_other_write(mode) {
s.push('w')
} else {
s.push('-')
}
if mode_other_exec(mode) {
if mode_sticky(mode) {
s.push('t')
} else {
s.push('x')
}
} else if mode_sticky(mode) {
s.push('T')
} else {
s.push('-')
}
s
}
#[allow(unused)]
pub fn get_mode_from_boxed_unix_int(meta: &Metadata) -> Option<u32> {
#[cfg(unix)]
{
Some(meta.mode())
}
#[cfg(not(unix))]
{
None
}
}
/// Generate a pair of functions: one that checks a mode bit on raw `u32`,
/// and one that extracts the mode from `Metadata` first.
macro_rules! mode_check {
($meta_fn:ident, $mode_fn:ident, $mask:expr, $expected:expr) => {
pub fn $meta_fn(meta: &Metadata) -> bool {
match get_mode_from_boxed_unix_int(meta) {
Some(mode) => $mode_fn(mode),
None => false,
}
}
pub fn $mode_fn(mode: u32) -> bool {
mode & $mask == $expected
}
};
}
mode_check!(user_read, mode_user_read, S_IRUSR, S_IRUSR);
mode_check!(user_write, mode_user_write, S_IWUSR, S_IWUSR);
mode_check!(user_exec, mode_user_exec, S_IXUSR, S_IXUSR);
mode_check!(group_read, mode_group_read, S_IRGRP, S_IRGRP);
mode_check!(group_write, mode_group_write, S_IWGRP, S_IWGRP);
mode_check!(group_exec, mode_group_exec, S_IXGRP, S_IXGRP);
mode_check!(other_read, mode_other_read, S_IROTH, S_IROTH);
mode_check!(other_write, mode_other_write, S_IWOTH, S_IWOTH);
mode_check!(other_exec, mode_other_exec, S_IXOTH, S_IXOTH);
mode_check!(suid_bit_set, mode_suid, S_ISUID, S_ISUID);
mode_check!(sgid_bit_set, mode_sgid, S_ISGID, S_ISGID);
mode_check!(sticky_bit_set, mode_sticky, S_ISVTX, S_ISVTX);
mode_check!(is_pipe, mode_is_pipe, S_IFMT, S_IFIFO);
mode_check!(is_char_device, mode_is_char_device, S_IFMT, S_IFCHR);
mode_check!(is_block_device, mode_is_block_device, S_IFMT, S_IFBLK);
mode_check!(is_socket, mode_is_socket, S_IFMT, S_IFSOCK);
pub fn user_all(meta: &Metadata) -> bool {
user_read(meta) && user_write(meta) && user_exec(meta)
}
pub fn mode_user_all(mode: u32) -> bool {
mode_user_read(mode) && mode_user_write(mode) && mode_user_exec(mode)
}
pub fn group_all(meta: &Metadata) -> bool {
group_read(meta) && group_write(meta) && group_exec(meta)
}
pub fn mode_group_all(mode: u32) -> bool {
mode_group_read(mode) && mode_group_write(mode) && mode_group_exec(mode)
}
pub fn other_all(meta: &Metadata) -> bool {
other_read(meta) && other_write(meta) && other_exec(meta)
}
pub fn mode_other_all(mode: u32) -> bool {
mode_other_read(mode) && mode_other_write(mode) && mode_other_exec(mode)
}
#[cfg(unix)]
pub fn mode_is_directory(mode: u32) -> bool {
mode & S_IFMT == S_IFDIR
}
#[cfg(unix)]
pub fn mode_is_link(mode: u32) -> bool {
mode & S_IFMT == S_IFLNK
}
const S_IRUSR: u32 = 0o400;
const S_IWUSR: u32 = 0o200;
const S_IXUSR: u32 = 0o100;
const S_IRGRP: u32 = 0o40;
const S_IWGRP: u32 = 0o20;
const S_IXGRP: u32 = 0o10;
const S_IROTH: u32 = 0o4;
const S_IWOTH: u32 = 0o2;
const S_IXOTH: u32 = 0o1;
const S_ISUID: u32 = 0o4000;
const S_ISGID: u32 = 0o2000;
const S_ISVTX: u32 = 0o1000;
const S_IFMT: u32 = 0o170000;
const S_IFBLK: u32 = 0o60000;
#[cfg(unix)]
const S_IFDIR: u32 = 0o40000;
const S_IFCHR: u32 = 0o20000;
const S_IFIFO: u32 = 0o10000;
#[cfg(unix)]
const S_IFLNK: u32 = 0o120000;
const S_IFSOCK: u32 = 0o140000;
#[cfg(windows)]
fn get_mode_windows(mode: u32) -> String {
const FILE_ATTRIBUTE_ARCHIVE: u32 = 0x20;
const FILE_ATTRIBUTE_COMPRESSED: u32 = 0x800;
const FILE_ATTRIBUTE_DEVICE: u32 = 0x40;
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
const FILE_ATTRIBUTE_ENCRYPTED: u32 = 0x4000;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
const FILE_ATTRIBUTE_INTEGRITY_STREAM: u32 = 0x8000;
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
const FILE_ATTRIBUTE_NOT_CONTENT_INDEXED: u32 = 0x2000;
const FILE_ATTRIBUTE_NO_SCRUB_DATA: u32 = 0x20000;
const FILE_ATTRIBUTE_OFFLINE: u32 = 0x1000;
const FILE_ATTRIBUTE_READONLY: u32 = 0x1;
const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x400000;
const FILE_ATTRIBUTE_RECALL_ON_OPEN: u32 = 0x40000;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x200;
const FILE_ATTRIBUTE_SYSTEM: u32 = 0x4;
const FILE_ATTRIBUTE_TEMPORARY: u32 = 0x100;
const FILE_ATTRIBUTE_VIRTUAL: u32 = 0x10000;
let mut v = vec![];
if mode & FILE_ATTRIBUTE_ARCHIVE == FILE_ATTRIBUTE_ARCHIVE {
v.push("Archive");
}
if mode & FILE_ATTRIBUTE_COMPRESSED == FILE_ATTRIBUTE_COMPRESSED {
v.push("Compressed");
}
if mode & FILE_ATTRIBUTE_DEVICE == FILE_ATTRIBUTE_DEVICE {
v.push("Device");
}
if mode & FILE_ATTRIBUTE_DIRECTORY == FILE_ATTRIBUTE_DIRECTORY {
v.push("Directory");
}
if mode & FILE_ATTRIBUTE_ENCRYPTED == FILE_ATTRIBUTE_ENCRYPTED {
v.push("Encrypted");
}
if mode & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN {
v.push("Hidden");
}
if mode & FILE_ATTRIBUTE_INTEGRITY_STREAM == FILE_ATTRIBUTE_INTEGRITY_STREAM {
v.push("Integrity Stream");
}
if mode & FILE_ATTRIBUTE_NORMAL == FILE_ATTRIBUTE_NORMAL {
v.push("Normal");
}
if mode & FILE_ATTRIBUTE_NOT_CONTENT_INDEXED == FILE_ATTRIBUTE_NOT_CONTENT_INDEXED {
v.push("Not indexed");
}
if mode & FILE_ATTRIBUTE_NO_SCRUB_DATA == FILE_ATTRIBUTE_NO_SCRUB_DATA {
v.push("No scrub data");
}
if mode & FILE_ATTRIBUTE_OFFLINE == FILE_ATTRIBUTE_OFFLINE {
v.push("Offline");
}
if mode & FILE_ATTRIBUTE_READONLY == FILE_ATTRIBUTE_READONLY {
v.push("Readonly");
}
if mode & FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS == FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS {
v.push("Recall on data access");
}
if mode & FILE_ATTRIBUTE_RECALL_ON_OPEN == FILE_ATTRIBUTE_RECALL_ON_OPEN {
v.push("Recall on open");
}
if mode & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT {
v.push("Reparse point");
}
if mode & FILE_ATTRIBUTE_SPARSE_FILE == FILE_ATTRIBUTE_SPARSE_FILE {
v.push("Sparse");
}
if mode & FILE_ATTRIBUTE_SYSTEM == FILE_ATTRIBUTE_SYSTEM {
v.push("System");
}
if mode & FILE_ATTRIBUTE_TEMPORARY == FILE_ATTRIBUTE_TEMPORARY {
v.push("Temporary");
}
if mode & FILE_ATTRIBUTE_VIRTUAL == FILE_ATTRIBUTE_VIRTUAL {
v.push("Virtual");
}
v.join(", ")
}
#[allow(unused)]
pub fn get_uid(meta: &Metadata) -> Option<u32> {
#[cfg(unix)]
{
Some(meta.uid())
}
#[cfg(not(unix))]
{
None
}
}
#[allow(unused)]
pub fn get_gid(meta: &Metadata) -> Option<u32> {
#[cfg(unix)]
{
Some(meta.gid())
}
#[cfg(not(unix))]
{
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_mode() {
#[cfg(unix)]
{
// Regular file with rwxr-xr-- permissions (0754 in octal)
let mode = 0o100754;
assert_eq!(format_mode(mode), "-rwxr-xr--");
// Directory with rwxr-xr-x permissions (0755 in octal)
let mode = 0o40755;
assert_eq!(format_mode(mode), "drwxr-xr-x");
// Symbolic link with rwxrwxrwx permissions (0777 in octal)
let mode = 0o120777;
assert_eq!(format_mode(mode), "lrwxrwxrwx");
// File with setuid bit (4755 in octal)
let mode = 0o104755;
assert_eq!(format_mode(mode), "-rwsr-xr-x");
// File with setgid bit (2755 in octal)
let mode = 0o102755;
assert_eq!(format_mode(mode), "-rwxr-sr-x");
// Directory with sticky bit (1755 in octal)
let mode = 0o41755;
assert_eq!(format_mode(mode), "drwxr-xr-t");
}
#[cfg(windows)]
{
const FILE_ATTRIBUTE_READONLY: u32 = 0x1;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
let mode = FILE_ATTRIBUTE_READONLY;
assert_eq!(format_mode(mode), "Readonly");
let mode = FILE_ATTRIBUTE_HIDDEN;
assert_eq!(format_mode(mode), "Hidden");
let mode = FILE_ATTRIBUTE_DIRECTORY;
assert_eq!(format_mode(mode), "Directory");
let mode = FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_HIDDEN;
assert_eq!(format_mode(mode), "Hidden, Readonly");
}
}
#[test]
fn test_format_unix_mode() {
assert_eq!(format_unix_mode(0o100644), "-rw-r--r--");
assert_eq!(format_unix_mode(0o100755), "-rwxr-xr-x");
assert_eq!(format_unix_mode(0o40755), "drwxr-xr-x");
assert_eq!(format_unix_mode(0o120777), "lrwxrwxrwx");
assert_eq!(format_unix_mode(0o104755), "-rwsr-xr-x");
}
#[test]
fn test_mode_user_permissions() {
let mode = 0o754; // rwxr-xr--
assert!(mode_user_read(mode));
assert!(mode_user_write(mode));
assert!(mode_user_exec(mode));
assert!(mode_user_all(mode));
let mode = 0o654; // rw-r-xr--
assert!(mode_user_read(mode));
assert!(mode_user_write(mode));
assert!(!mode_user_exec(mode));
assert!(!mode_user_all(mode));
}
#[test]
fn test_mode_group_permissions() {
// Test group permission checks
let mode = 0o754; // rwxr-xr--
assert!(mode_group_read(mode));
assert!(!mode_group_write(mode));
assert!(mode_group_exec(mode));
assert!(!mode_group_all(mode));
let mode = 0o774; // rwxrwxr--
assert!(mode_group_read(mode));
assert!(mode_group_write(mode));
assert!(mode_group_exec(mode));
assert!(mode_group_all(mode));
}
#[test]
fn test_mode_other_permissions() {
let mode = 0o754; // rwxr-xr--
assert!(mode_other_read(mode));
assert!(!mode_other_write(mode));
assert!(!mode_other_exec(mode));
assert!(!mode_other_all(mode));
let mode = 0o757; // rwxr-xrwx
assert!(mode_other_read(mode));
assert!(mode_other_write(mode));
assert!(mode_other_exec(mode));
assert!(mode_other_all(mode));
}
#[test]
fn test_mode_special_bits() {
// Test setuid bit
let mode = 0o4755; // rwsr-xr-x
assert!(mode_suid(mode));
// Test setgid bit
let mode = 0o2755; // rwxr-sr-x
assert!(mode_sgid(mode));
// Test sticky bit (Unix only)
#[cfg(unix)]
{
let mode = 0o1755; // rwxr-xr-t
assert!(mode_sticky(mode));
}
}
#[test]
fn test_mode_file_types() {
// Test directory
#[cfg(unix)]
{
let mode = 0o40755; // drwxr-xr-x
assert!(mode_is_directory(mode));
assert!(!mode_is_link(mode));
}
// Test symbolic link
#[cfg(unix)]
{
let mode = 0o120755; // lrwxr-xr-x
assert!(mode_is_link(mode));
assert!(!mode_is_directory(mode));
}
// Test block device
let mode = 0o60644; // brw-r--r--
assert!(mode_is_block_device(mode));
// Test character device
let mode = 0o20644; // crw-r--r--
assert!(mode_is_char_device(mode));
// Test FIFO/pipe
let mode = 0o10644; // prw-r--r--
assert!(mode_is_pipe(mode));
// Test socket
let mode = 0o140644; // srw-r--r--
assert!(mode_is_socket(mode));
}
#[test]
fn test_block_device_not_char_device() {
let mode = 0o60644;
assert!(mode_is_block_device(mode));
assert!(
!mode_is_char_device(mode),
"block device should not be detected as char device"
);
}
#[test]
fn test_symlink_not_char_device() {
#[cfg(unix)]
{
let mode = 0o120777;
assert!(mode_is_link(mode));
assert!(
!mode_is_char_device(mode),
"symlink should not be detected as char device"
);
}
}
#[test]
fn test_socket_not_directory() {
#[cfg(unix)]
{
let mode = 0o140755;
assert!(mode_is_socket(mode));
assert!(
!mode_is_directory(mode),
"socket should not be detected as directory"
);
}
}
#[test]
fn test_block_device_not_directory() {
#[cfg(unix)]
{
let mode = 0o60755;
assert!(mode_is_block_device(mode));
assert!(
!mode_is_directory(mode),
"block device should not be detected as directory"
);
}
}
#[test]
fn test_get_uid_gid() {
// These functions are platform-specific, so we test the behavior
// rather than the actual values
#[cfg(unix)]
{
// On Unix, we should get Some value
use std::fs::File;
if let Ok(meta) = File::open("Cargo.toml").and_then(|f| f.metadata()) {
assert!(get_uid(&meta).is_some());
assert!(get_gid(&meta).is_some());
}
}
#[cfg(not(unix))]
{
// On non-Unix platforms, we should get None
use std::fs::File;
if let Ok(meta) = File::open("Cargo.toml").and_then(|f| f.metadata()) {
assert!(get_uid(&meta).is_none());
assert!(get_gid(&meta).is_none());
}
}
}
}
+286
View File
@@ -0,0 +1,286 @@
//! Defines the arithmetic operators used in the query language
use crate::util::Variant;
#[derive(Debug, Clone, Eq, Hash, PartialEq, PartialOrd, Serialize)]
pub enum LogicalOp {
And,
Or,
}
impl LogicalOp {
pub fn negate(&self) -> LogicalOp {
match self {
LogicalOp::And => LogicalOp::Or,
LogicalOp::Or => LogicalOp::And,
}
}
}
// Note: there are no `Between`/`NotBetween` variants. The parser rewrites
// `x between a and b` into `x >= a and x <= b` (and the negation into
// `x < a or x > b`), so a BETWEEN op never reaches evaluation.
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Serialize)]
pub enum Op {
Eq,
Ne,
Eeq,
Ene,
Gt,
Gte,
Lt,
Lte,
Rx,
NotRx,
Like,
NotLike,
In,
NotIn,
Exists,
NotExists,
}
impl Op {
pub fn from(text: &str) -> Option<Op> {
match text.to_lowercase().as_str() {
"=" | "==" | "eq" => Some(Op::Eq),
"!=" | "<>" | "ne" => Some(Op::Ne),
"===" | "eeq" => Some(Op::Eeq),
"!==" | "ene" => Some(Op::Ene),
">" | "gt" => Some(Op::Gt),
">=" | "gte" | "ge" => Some(Op::Gte),
"<" | "lt" => Some(Op::Lt),
"<=" | "lte" | "le" => Some(Op::Lte),
"~=" | "=~" | "regexp" | "rx" => Some(Op::Rx),
"!=~" | "!~=" | "notrx" => Some(Op::NotRx),
"like" => Some(Op::Like),
"notlike" => Some(Op::NotLike),
"in" => Some(Op::In),
"notin" => Some(Op::NotIn),
"exists" => Some(Op::Exists),
"notexists" => Some(Op::NotExists),
_ => None,
}
}
pub fn from_with_not(text: &str, not: bool) -> Option<Op> {
let op = Op::from(text);
match op {
Some(op) if not => Some(Self::negate(op)),
_ => op,
}
}
pub fn negate(op: Op) -> Op {
match op {
Op::Eq => Op::Ne,
Op::Ne => Op::Eq,
Op::Eeq => Op::Ene,
Op::Ene => Op::Eeq,
Op::Gt => Op::Lte,
Op::Lte => Op::Gt,
Op::Lt => Op::Gte,
Op::Gte => Op::Lt,
Op::Rx => Op::NotRx,
Op::NotRx => Op::Rx,
Op::Like => Op::NotLike,
Op::NotLike => Op::Like,
Op::In => Op::NotIn,
Op::NotIn => Op::In,
Op::Exists => Op::NotExists,
Op::NotExists => Op::Exists,
}
}
}
#[derive(Debug, Clone, PartialOrd, PartialEq, Eq, Hash, Serialize)]
pub enum ArithmeticOp {
Add,
Subtract,
Divide,
Multiply,
Modulo,
}
impl ArithmeticOp {
pub fn from(text: &str) -> Option<ArithmeticOp> {
match text.to_lowercase().as_str() {
"+" | "plus" => Some(ArithmeticOp::Add),
"-" | "minus" => Some(ArithmeticOp::Subtract),
"*" | "mul" => Some(ArithmeticOp::Multiply),
"/" | "div" => Some(ArithmeticOp::Divide),
"%" | "mod" => Some(ArithmeticOp::Modulo),
_ => None,
}
}
pub fn calc(&self, left: &Variant, right: &Variant) -> Result<Variant, String> {
let right_val = right.to_float();
if matches!(self, ArithmeticOp::Divide | ArithmeticOp::Modulo) && right_val == 0.0 {
return Err("Division by zero".to_string());
}
let result = match &self {
ArithmeticOp::Add => left.to_float() + right_val,
ArithmeticOp::Subtract => left.to_float() - right_val,
ArithmeticOp::Multiply => left.to_float() * right_val,
ArithmeticOp::Divide => left.to_float() / right_val,
ArithmeticOp::Modulo => left.to_float() % right_val,
};
if !result.is_finite() {
return Err(format!("Arithmetic overflow: result is {}", result));
}
Ok(Variant::from_float(result))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn op_from_between_is_none() {
// BETWEEN is rewritten by the parser into >=/<= comparisons and has
// no Op variant of its own.
assert_eq!(Op::from("between"), None);
assert_eq!(Op::from("notbetween"), None);
}
#[test]
fn op_from_notin() {
assert_eq!(Op::from("notin"), Some(Op::NotIn));
}
#[test]
fn op_from_notexists() {
assert_eq!(Op::from("notexists"), Some(Op::NotExists));
}
#[test]
fn calc_divide_by_zero_returns_error() {
let result = ArithmeticOp::Divide.calc(
&Variant::from_float(1.0),
&Variant::from_float(0.0),
);
assert!(result.is_err());
}
#[test]
fn calc_modulo_by_zero_returns_error() {
let result = ArithmeticOp::Modulo.calc(
&Variant::from_float(1.0),
&Variant::from_float(0.0),
);
assert!(result.is_err());
}
#[test]
fn calc_zero_divided_by_zero_returns_error() {
let result = ArithmeticOp::Divide.calc(
&Variant::from_float(0.0),
&Variant::from_float(0.0),
);
assert!(result.is_err());
}
#[test]
fn op_from_notlike_exists() {
assert_eq!(Op::from("notlike"), Some(Op::NotLike));
}
#[test]
fn op_from_notrx_exists() {
assert_eq!(Op::from("notrx"), Some(Op::NotRx));
}
#[test]
fn op_negate_roundtrip() {
let ops = vec![
Op::Eq, Op::Ne, Op::Eeq, Op::Ene, Op::Gt, Op::Gte, Op::Lt, Op::Lte,
Op::Rx, Op::NotRx, Op::Like, Op::NotLike,
Op::In, Op::NotIn, Op::Exists, Op::NotExists,
];
for op in ops {
assert_eq!(Op::negate(Op::negate(op)), op);
}
}
#[test]
fn op_from_with_not_negates() {
assert_eq!(Op::from_with_not("eq", true), Some(Op::Ne));
assert_eq!(Op::from_with_not("eq", false), Some(Op::Eq));
}
#[test]
fn op_from_with_not_unknown_returns_none() {
assert_eq!(Op::from_with_not("garbage", true), None);
assert_eq!(Op::from_with_not("garbage", false), None);
}
#[test]
fn arithmetic_op_from_all_variants() {
assert_eq!(ArithmeticOp::from("+"), Some(ArithmeticOp::Add));
assert_eq!(ArithmeticOp::from("plus"), Some(ArithmeticOp::Add));
assert_eq!(ArithmeticOp::from("-"), Some(ArithmeticOp::Subtract));
assert_eq!(ArithmeticOp::from("minus"), Some(ArithmeticOp::Subtract));
assert_eq!(ArithmeticOp::from("*"), Some(ArithmeticOp::Multiply));
assert_eq!(ArithmeticOp::from("mul"), Some(ArithmeticOp::Multiply));
assert_eq!(ArithmeticOp::from("/"), Some(ArithmeticOp::Divide));
assert_eq!(ArithmeticOp::from("div"), Some(ArithmeticOp::Divide));
assert_eq!(ArithmeticOp::from("%"), Some(ArithmeticOp::Modulo));
assert_eq!(ArithmeticOp::from("mod"), Some(ArithmeticOp::Modulo));
}
#[test]
fn arithmetic_op_from_unknown() {
assert_eq!(ArithmeticOp::from("garbage"), None);
}
#[test]
fn calc_basic_operations() {
let a = Variant::from_float(10.0);
let b = Variant::from_float(3.0);
assert_eq!(ArithmeticOp::Add.calc(&a, &b).unwrap().to_float(), 13.0);
assert_eq!(ArithmeticOp::Subtract.calc(&a, &b).unwrap().to_float(), 7.0);
assert_eq!(ArithmeticOp::Multiply.calc(&a, &b).unwrap().to_float(), 30.0);
let div = ArithmeticOp::Divide.calc(&a, &b).unwrap().to_float();
assert!((div - 10.0 / 3.0).abs() < 1e-10);
assert_eq!(ArithmeticOp::Modulo.calc(&a, &b).unwrap().to_float(), 1.0);
}
#[test]
fn calc_add_overflow_returns_error() {
let result = ArithmeticOp::Add.calc(
&Variant::from_float(f64::MAX),
&Variant::from_float(f64::MAX),
);
assert!(result.is_err(), "f64::MAX + f64::MAX should error, not silently become 0");
}
#[test]
fn calc_multiply_overflow_returns_error() {
let result = ArithmeticOp::Multiply.calc(
&Variant::from_float(f64::MAX),
&Variant::from_float(2.0),
);
assert!(result.is_err(), "f64::MAX * 2 should error, not silently become 0");
}
#[test]
fn calc_subtract_overflow_returns_error() {
let result = ArithmeticOp::Subtract.calc(
&Variant::from_float(f64::MAX),
&Variant::from_float(-f64::MAX),
);
assert!(result.is_err(), "f64::MAX - (-f64::MAX) should error, not silently become 0");
}
#[test]
fn logical_op_negate() {
assert_eq!(LogicalOp::And.negate(), LogicalOp::Or);
assert_eq!(LogicalOp::Or.negate(), LogicalOp::And);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Handles export of results in CSV format
use crate::output::ResultsFormatter;
#[derive(Default)]
pub struct CsvFormatter {
records: Vec<String>,
}
impl ResultsFormatter for CsvFormatter {
fn header(&mut self, _: &str, _: usize) -> Option<String> {
None
}
fn row_started(&mut self) -> Option<String> {
None
}
fn format_element(&mut self, _: &str, record: &str, _is_last: bool) -> Option<String> {
self.records.push(record.to_owned());
None
}
fn row_ended(&mut self) -> Option<String> {
// Write into a plain byte buffer: the csv writer flushes its internal
// 8KB buffer at arbitrary byte offsets, which a UTF-8-validating sink
// would reject mid-character, silently dropping the whole row.
let mut csv_writer = csv::Writer::from_writer(Vec::new());
let write_result = csv_writer.write_record(&self.records);
self.records.clear();
if write_result.is_err() {
return None;
}
match csv_writer.into_inner() {
Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
Err(_) => None,
}
}
fn footer(&mut self) -> Option<String> {
None
}
}
#[cfg(test)]
mod test {
use crate::output::csv::CsvFormatter;
use crate::output::test::write_test_items;
#[test]
fn test() {
let result = write_test_items(&mut CsvFormatter::default());
assert_eq!("foo_value,BAR value\n123,\n", result);
}
#[test]
fn multibyte_row_larger_than_internal_buffer_is_not_dropped() {
use crate::output::ResultsFormatter;
// A row larger than the csv writer's 8KB internal buffer used to be
// flushed at a non-character byte boundary and silently discarded.
let mut formatter = CsvFormatter::default();
let big_value = "é".repeat(6000); // 12000 bytes
formatter.format_element("col1", &big_value, false);
formatter.format_element("col2", "x", true);
let row = formatter.row_ended().expect("row must be produced");
assert!(row.contains(&big_value));
assert!(row.ends_with("x\n"));
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Handles export of results in line-separated, list-separated, and tab-separated formats
use crate::output::ResultsFormatter;
pub const LINES_FORMATTER: FlatWriter = FlatWriter {
record_separator: '\n',
line_separator: Some('\n'),
};
pub const LIST_FORMATTER: FlatWriter = FlatWriter {
record_separator: '\0',
line_separator: Some('\0'),
};
pub const TABS_FORMATTER: FlatWriter = FlatWriter {
record_separator: '\t',
line_separator: Some('\n'),
};
pub struct FlatWriter {
record_separator: char,
line_separator: Option<char>,
}
impl ResultsFormatter for FlatWriter {
fn header(&mut self, _: &str, _: usize) -> Option<String> {
None
}
fn row_started(&mut self) -> Option<String> {
None
}
fn format_element(&mut self, _: &str, record: &str, is_last: bool) -> Option<String> {
match is_last {
true => Some(record.to_string()),
false => Some(format!("{}{}", record, self.record_separator)),
}
}
fn row_ended(&mut self) -> Option<String> {
self.line_separator.map(String::from)
}
fn footer(&mut self) -> Option<String> {
None
}
}
#[cfg(test)]
mod test {
#![allow(const_item_mutation)]
use crate::output::flat::{LINES_FORMATTER, LIST_FORMATTER, TABS_FORMATTER};
use crate::output::test::write_test_items;
#[test]
fn test_lines() {
let result = write_test_items(&mut LINES_FORMATTER);
assert_eq!("foo_value\nBAR value\n123\n\n", result);
}
#[test]
fn test_list() {
let result = write_test_items(&mut LIST_FORMATTER);
assert_eq!("foo_value\x00BAR value\x00123\x00\x00", result);
}
#[test]
fn test_tab() {
let result = write_test_items(&mut TABS_FORMATTER);
assert_eq!("foo_value\tBAR value\n123\t\n", result);
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Handles export of results in HTML format
use crate::output::ResultsFormatter;
pub struct HtmlFormatter;
fn escape_html(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => result.push_str("&amp;"),
'<' => result.push_str("&lt;"),
'>' => result.push_str("&gt;"),
'"' => result.push_str("&quot;"),
_ => result.push(c),
}
}
result
}
impl ResultsFormatter for HtmlFormatter {
fn header(&mut self, raw_query: &str, col_count: usize) -> Option<String> {
let escaped = escape_html(raw_query);
Some(format!("<html><head><title>{}</title></head><body><table><tr><th colspan=\"{}\">{}</th></tr>", escaped, col_count, escaped))
}
fn row_started(&mut self) -> Option<String> {
Some("<tr>".to_owned())
}
fn format_element(&mut self, _: &str, record: &str, _is_last: bool) -> Option<String> {
Some(format!("<td>{}</td>", escape_html(record)))
}
fn row_ended(&mut self) -> Option<String> {
Some("</tr>".to_owned())
}
fn footer(&mut self) -> Option<String> {
Some("</table></body></html>".to_owned())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::output::html::HtmlFormatter;
use crate::output::test::write_test_items;
#[test]
fn test() {
let result = write_test_items(&mut HtmlFormatter);
assert_eq!("<html><head><title>select key, value</title></head><body><table><tr><th colspan=\"2\">select key, value</th></tr><tr><td>foo_value</td><td>BAR value</td></tr><tr><td>123</td><td></td></tr></table></body></html>", result);
}
#[test]
fn test_escape_html() {
assert_eq!(escape_html("<script>alert(1)</script>"), "&lt;script&gt;alert(1)&lt;/script&gt;");
assert_eq!(escape_html("a&b"), "a&amp;b");
assert_eq!(escape_html("\"hello\""), "&quot;hello&quot;");
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Handles export of results in JSON format
use std::collections::HashMap;
use crate::output::ResultsFormatter;
#[derive(Default)]
pub struct JsonFormatter {
/// Column values in the order they were emitted. Stored as a `Vec` rather
/// than a map so we preserve the user's SELECT column order and so we can
/// disambiguate duplicate column names below.
row: Vec<(String, String)>,
}
impl ResultsFormatter for JsonFormatter {
fn header(&mut self, _: &str, _: usize) -> Option<String> {
Some("[".to_owned())
}
fn row_started(&mut self) -> Option<String> {
None
}
fn format_element(&mut self, name: &str, record: &str, _is_last: bool) -> Option<String> {
self.row.push((name.to_owned(), record.to_owned()));
None
}
fn row_ended(&mut self) -> Option<String> {
// Resolve duplicate column names by suffixing repeats with `_2`,
// `_3`, ... so the emitted object is always valid JSON with unique
// keys. The first occurrence keeps its original name.
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut out = String::from("{");
for (i, (name, value)) in self.row.iter().enumerate() {
if i > 0 {
out.push(',');
}
let n = counts.entry(name.as_str()).or_insert(0);
*n += 1;
let resolved = if *n == 1 {
name.clone()
} else {
format!("{}_{}", name, n)
};
out.push_str(&serde_json::to_string(&resolved).unwrap_or_else(|_| "\"\"".to_string()));
out.push(':');
out.push_str(&serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()));
}
out.push('}');
self.row.clear();
Some(out)
}
fn footer(&mut self) -> Option<String> {
Some("]".to_owned())
}
fn row_separator(&self) -> Option<String> {
Some(",".to_owned())
}
}
#[cfg(test)]
mod test {
use crate::output::ResultsFormatter;
use crate::output::json::JsonFormatter;
use crate::output::test::write_test_items;
#[test]
fn test() {
let result = write_test_items(&mut JsonFormatter::default());
// Columns must appear in the order they were emitted (foo, bar),
// not alphabetised by a BTreeMap.
assert_eq!(
r#"[{"foo":"foo_value","bar":"BAR value"},{"foo":"123","bar":""}]"#,
result
);
}
#[test]
fn duplicate_column_names_are_suffixed() {
// Duplicate column names within one row must produce a *valid* JSON
// object: first occurrence keeps the name, subsequent ones are
// suffixed with `_2`, `_3`, ...
let mut f = JsonFormatter::default();
let mut out = String::new();
if let Some(s) = f.header("", 3) { out.push_str(&s); }
if let Some(s) = f.row_started() { out.push_str(&s); }
if let Some(s) = f.format_element("name", "first", false) { out.push_str(&s); }
if let Some(s) = f.format_element("name", "second", false) { out.push_str(&s); }
if let Some(s) = f.format_element("name", "third", true) { out.push_str(&s); }
if let Some(s) = f.row_ended() { out.push_str(&s); }
if let Some(s) = f.footer() { out.push_str(&s); }
assert_eq!(
out,
r#"[{"name":"first","name_2":"second","name_3":"third"}]"#,
);
}
#[test]
fn duplicate_counter_resets_between_rows() {
// The duplicate-suffix counter is per-row state and must not leak
// into subsequent rows.
let mut f = JsonFormatter::default();
let mut out = String::new();
if let Some(s) = f.header("", 1) { out.push_str(&s); }
if let Some(s) = f.row_started() { out.push_str(&s); }
if let Some(s) = f.format_element("name", "a", false) { out.push_str(&s); }
if let Some(s) = f.format_element("name", "b", true) { out.push_str(&s); }
if let Some(s) = f.row_ended() { out.push_str(&s); }
if let Some(s) = f.row_separator() { out.push_str(&s); }
if let Some(s) = f.row_started() { out.push_str(&s); }
if let Some(s) = f.format_element("name", "c", true) { out.push_str(&s); }
if let Some(s) = f.row_ended() { out.push_str(&s); }
if let Some(s) = f.footer() { out.push_str(&s); }
assert_eq!(
out,
r#"[{"name":"a","name_2":"b"},{"name":"c"}]"#,
);
}
}
+131
View File
@@ -0,0 +1,131 @@
use std::io::Write;
use crate::output::csv::CsvFormatter;
use crate::output::flat::{LINES_FORMATTER, LIST_FORMATTER, TABS_FORMATTER};
use crate::output::html::HtmlFormatter;
use crate::output::json::JsonFormatter;
pub(crate) use crate::query::OutputFormat;
mod csv;
mod flat;
mod html;
mod json;
pub trait ResultsFormatter {
fn header(&mut self, raw_query: &str, col_count: usize) -> Option<String>;
fn row_started(&mut self) -> Option<String>;
fn format_element(&mut self, name: &str, record: &str, is_last: bool) -> Option<String>;
fn row_ended(&mut self) -> Option<String>;
fn footer(&mut self) -> Option<String>;
fn row_separator(&self) -> Option<String> {
None
}
}
pub struct ResultsWriter {
formatter: Box<dyn ResultsFormatter>,
}
impl ResultsWriter {
pub fn new(format: &OutputFormat) -> ResultsWriter {
ResultsWriter {
formatter: select_formatter(format),
}
}
pub fn write_header(&mut self, raw_query: &str, col_count: usize, writer: &mut dyn Write) -> std::io::Result<()> {
self.formatter
.header(raw_query, col_count)
.map_or(Ok(()), |value| write!(writer, "{}", value))
}
pub fn write_row_separator(&mut self, writer: &mut dyn Write) -> std::io::Result<()> {
self.formatter
.row_separator()
.map_or(Ok(()), |value| write!(writer, "{}", value))
}
pub fn write_row(
&mut self,
writer: &mut dyn Write,
values: Vec<(String, String)>,
) -> std::io::Result<()> {
self.write_row_start(writer)?;
let len = values.len();
for (pos, (name, value)) in values.iter().enumerate() {
self.write_row_item(writer, name, value, pos == len - 1)?;
}
self.write_row_end(writer)
}
pub fn write_footer(&mut self, writer: &mut dyn Write) -> std::io::Result<()> {
self.formatter
.footer()
.map_or(Ok(()), |value| write!(writer, "{}", value))
}
fn write_row_start(&mut self, writer: &mut dyn Write) -> std::io::Result<()> {
self.formatter
.row_started()
.map_or(Ok(()), |value| write!(writer, "{}", value))
}
fn write_row_item(
&mut self,
writer: &mut dyn Write,
name: &str,
value: &str,
is_last: bool,
) -> std::io::Result<()> {
self.formatter
.format_element(name, value, is_last)
.map_or(Ok(()), |value| write!(writer, "{}", value))
}
fn write_row_end(&mut self, writer: &mut dyn Write) -> std::io::Result<()> {
self.formatter
.row_ended()
.map_or(Ok(()), |value| write!(writer, "{}", value))
}
}
fn select_formatter(format: &OutputFormat) -> Box<dyn ResultsFormatter> {
match format {
OutputFormat::Tabs => Box::new(TABS_FORMATTER),
OutputFormat::Lines => Box::new(LINES_FORMATTER),
OutputFormat::List => Box::new(LIST_FORMATTER),
OutputFormat::Csv => Box::<CsvFormatter>::default(),
OutputFormat::Json => Box::<JsonFormatter>::default(),
OutputFormat::Html => Box::new(HtmlFormatter),
}
}
#[cfg(test)]
mod test {
use crate::output::ResultsFormatter;
pub(crate) fn write_test_items<T: ResultsFormatter>(under_test: &mut T) -> String {
let mut result = String::from("");
if let Some(s) = under_test.header("select key, value", 2) { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.row_started() { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.format_element("foo", "foo_value", false) { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.format_element("bar", "BAR value", true) { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.row_ended() { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.row_separator() { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.row_started() { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.format_element("foo", "123", false) { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.format_element("bar", "", true) { let _: () = result.push_str(&s); }
if let Some(s) = under_test
.row_ended() { let _: () = result.push_str(&s); }
if let Some(s) = under_test.footer() { let _: () = result.push_str(&s); }
result
}
}
+3467
View File
File diff suppressed because it is too large Load Diff
+352
View File
@@ -0,0 +1,352 @@
//! Defines the query struct and related types.
//! Query parsing is handled in the `parser` module
use std::collections::HashSet;
use crate::expr::Expr;
use crate::field::Field;
use crate::query::TraversalMode::Bfs;
#[derive(Debug, Clone, PartialOrd, PartialEq, Eq, Hash, Serialize)]
/// Represents a query to be executed on .
///
pub struct Query {
/// File fields to be selected
pub fields: Vec<Expr>,
/// Root directories to search
pub roots: Vec<Root>,
/// "where" filter expression
pub expr: Option<Expr>,
/// Fields to group by
pub grouping_fields: Vec<Expr>,
/// Fields to order by
pub ordering_fields: Vec<Expr>,
/// Ordering direction (true for asc, false for desc)
pub ordering_asc: Vec<bool>,
/// Max amount of results to return
pub limit: u32,
pub offset: u32,
/// Output format
pub output_format: OutputFormat,
pub raw_query: String,
}
impl Query {
pub fn get_all_fields(&self) -> HashSet<Field> {
let mut result = HashSet::new();
for column_expr in &self.fields {
result.extend(column_expr.get_required_fields());
}
// Ordering expressions may reference columns that are not selected
// (e.g. `select ext, count(*) ... order by max(modified)`); the
// aggregation scan must collect their per-file values too.
for ordering_expr in &self.ordering_fields {
result.extend(ordering_expr.get_required_fields());
}
result
}
pub fn is_ordered(&self) -> bool {
!self.ordering_fields.is_empty()
}
pub fn has_aggregate_column(&self) -> bool {
self.fields.iter().any(|f| f.has_aggregate_function())
}
/// True when results are produced per group rather than per file: either
/// an aggregate column is selected, or GROUP BY is present. Like in SQL,
/// `select ext from dir group by ext` collapses rows into distinct groups
/// even though no aggregate function appears in the SELECT list.
pub fn is_aggregated(&self) -> bool {
self.has_aggregate_column() || !self.grouping_fields.is_empty()
}
}
#[derive(Debug, Clone, PartialOrd, PartialEq, Eq, Hash, Serialize)]
/// Represents a root directory to start the search from, with traversal options.
/// When `subquery` is present, the root represents the result set of an inner
/// query rather than a filesystem path; `path` is unused in that case.
pub struct Root {
pub path: String,
pub options: RootOptions,
pub subquery: Option<Box<Query>>,
}
macro_rules! root_options {
(
$(#[$struct_attrs:meta])*
$vis:vis struct $struct_name:ident {
$(
$(
@text = [$($text:literal),*], description = $description:literal
)+
$(#[$field_attrs:meta])*
$field_vis:vis $field:ident: $field_type:ty
),*
$(,)?
}
) => {
$(#[$struct_attrs])*
$vis struct $struct_name {
$(
$(#[$field_attrs])*
$field_vis $field: $field_type,
)*
}
impl $struct_name {
pub fn get_names_and_descriptions() -> Vec<(Vec<&'static str>, &'static str)> {
vec![
$(
$(#[$field_attrs])*
$(
(vec![$($text,)*], $description),
)+
)*
]
}
}
};
}
root_options! {
#[derive(Debug, Clone, PartialOrd, PartialEq, Eq, Hash, Serialize)]
pub struct RootOptions {
@text = ["mindepth"], description = "Minimum depth to search"
pub min_depth: u32,
@text = ["maxdepth", "depth"], description = "Maximum depth to search"
pub max_depth: u32,
@text = ["archives", "arc"], description = "Whether to search archives"
pub archives: bool,
@text = ["symlinks", "sym"], description = "Whether to follow symlinks"
pub symlinks: bool,
@text = ["gitignore", "git"], description = "Search respects .gitignore files found"
@text = ["nogitignore", "nogit"], description = "Disable .gitignore parsing during the search"
pub gitignore: Option<bool>,
@text = ["hgignore", "hg"], description = "Search respects .hgignore files found"
@text = ["nohgignore", "nohg"], description = "Disable .hgignore parsing during the search"
pub hgignore: Option<bool>,
@text = ["dockerignore", "docker"], description = "Search respects .dockerignore files found"
@text = ["nodockerignore", "nodocker"], description = "Disable .dockerignore parsing during the search"
pub dockerignore: Option<bool>,
@text = ["dfs"], description = "Depth-first search mode"
@text = ["bfs"], description = "Breadth-first search mode (default)"
pub traversal: TraversalMode,
@text = ["regexp", "rx"], description = "Treat the path as a regular expression"
pub regexp: bool,
@text = ["as"], description = "Alias for the root path"
pub alias: Option<String>,
}
}
impl RootOptions {
pub fn new() -> RootOptions {
RootOptions {
min_depth: 0,
max_depth: 0,
archives: false,
symlinks: false,
gitignore: None,
hgignore: None,
dockerignore: None,
traversal: Bfs,
regexp: false,
alias: None,
}
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub fn from(
min_depth: u32,
max_depth: u32,
archives: bool,
symlinks: bool,
gitignore: Option<bool>,
hgignore: Option<bool>,
dockerignore: Option<bool>,
traversal: TraversalMode,
regexp: bool,
alias: Option<String>,
) -> RootOptions {
RootOptions {
min_depth,
max_depth,
archives,
symlinks,
gitignore,
hgignore,
dockerignore,
traversal,
regexp,
alias,
}
}
}
impl Root {
pub fn new(path: String, options: RootOptions) -> Root {
Root { path, options, subquery: None }
}
pub fn from_subquery(subquery: Query, options: RootOptions) -> Root {
Root {
path: String::new(),
options,
subquery: Some(Box::new(subquery)),
}
}
pub fn default(options: Option<RootOptions>) -> Root {
Root {
path: String::from("."),
options: options.unwrap_or_else(RootOptions::new),
subquery: None,
}
}
pub fn clone_with_path(new_path: String, source: Root) -> Root {
Root {
path: new_path,
..source
}
}
#[cfg(test)]
pub fn is_subquery(&self) -> bool {
self.subquery.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialOrd, PartialEq, Eq, Hash, Serialize)]
pub enum TraversalMode {
Bfs,
Dfs,
}
macro_rules! output_format {
(
$(#[$enum_attrs:meta])*
$vis:vis enum $enum_name:ident {
$(
@text = $text:literal
@description = $description:literal
$(#[$variant_attrs:meta])*
$(@supports_colorization = $colorized:literal)?
$variant:ident$(,)?
)*
}
) => {
$(#[$enum_attrs])*
$vis enum $enum_name {
$(
$(#[$variant_attrs])*
$variant,
)*
}
impl $enum_name {
pub fn from(s: &str) -> Option<OutputFormat> {
let s = s.to_lowercase();
match s.as_str() {
$(
$text => Some($enum_name::$variant),
)*
_ => None,
}
}
pub fn get_names_and_descriptions() -> Vec<(&'static str, &'static str)> {
vec![
$(
($text, $description),
)*
]
}
pub fn supports_colorization(&self) -> bool {
match self {
$(
$(#[$variant_attrs])*
$enum_name::$variant => {
false $(|| stringify!($colorized) == "true")?
}
)*
}
}
}
};
}
output_format! {
#[derive(Debug, Clone, PartialOrd, PartialEq, Eq, Hash, Serialize)]
pub enum OutputFormat {
@text = "tabs"
@description = "Tab-separated values (default)"
@supports_colorization = true
Tabs,
@text = "lines"
@description = "One item per line"
@supports_colorization = true
Lines,
@text = "list"
@description = "Entire output onto a single line for xargs"
List,
@text = "csv"
@description = "Comma-separated values"
Csv,
@text = "json"
@description = "JSON format"
Json,
@text = "html"
@description = "HTML format"
Html,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_supports_colorization_tabs() {
assert!(
OutputFormat::Tabs.supports_colorization(),
"Tabs should support colorization"
);
}
#[test]
fn test_supports_colorization_lines() {
assert!(
OutputFormat::Lines.supports_colorization(),
"Lines should support colorization"
);
}
#[test]
fn test_no_colorization_csv() {
assert!(
!OutputFormat::Csv.supports_colorization(),
"Csv should not support colorization"
);
}
}
+3200
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
/// POSIX ACL parsing from extended attributes.
///
/// Parses the binary format stored in `system.posix_acl_access` and
/// `system.posix_acl_default` extended attributes on Linux.
///
/// Binary format (POSIX ACL version 2):
/// - Header: 4 bytes (version u32 LE = 0x0002)
/// - Each entry: 8 bytes
/// - tag: u16 LE
/// - perm: u16 LE
/// - id: u32 LE (uid or gid, 0xFFFFFFFF for owner/group/mask/other)
const ACL_VERSION: u32 = 0x0002;
const ACL_ENTRY_SIZE: usize = 8;
const ACL_HEADER_SIZE: usize = 4;
const ACL_TAG_USER_OBJ: u16 = 0x0001;
const ACL_TAG_USER: u16 = 0x0002;
const ACL_TAG_GROUP_OBJ: u16 = 0x0004;
const ACL_TAG_GROUP: u16 = 0x0008;
const ACL_TAG_MASK: u16 = 0x0010;
const ACL_TAG_OTHER: u16 = 0x0020;
const ACL_PERM_READ: u16 = 0x04;
const ACL_PERM_WRITE: u16 = 0x02;
const ACL_PERM_EXEC: u16 = 0x01;
#[cfg(test)]
const ACL_UNDEFINED_ID: u32 = 0xFFFFFFFF;
#[derive(Debug, Clone, PartialEq)]
pub enum AclTag {
UserObj,
User(u32),
GroupObj,
Group(u32),
Mask,
Other,
}
#[derive(Debug, Clone)]
pub struct AclEntry {
pub tag: AclTag,
pub permissions: u16,
}
fn format_permissions(perm: u16) -> String {
let r = if perm & ACL_PERM_READ != 0 { 'r' } else { '-' };
let w = if perm & ACL_PERM_WRITE != 0 { 'w' } else { '-' };
let x = if perm & ACL_PERM_EXEC != 0 { 'x' } else { '-' };
format!("{}{}{}", r, w, x)
}
fn resolve_uid(uid: u32) -> String {
#[cfg(all(unix, feature = "users"))]
{
use uzers::Users;
let cache = uzers::UsersCache::new();
if let Some(user) = cache.get_user_by_uid(uid) {
return user.name().to_string_lossy().to_string();
}
}
#[allow(unreachable_code)]
uid.to_string()
}
fn resolve_gid(gid: u32) -> String {
#[cfg(all(unix, feature = "users"))]
{
use uzers::Groups;
let cache = uzers::UsersCache::new();
if let Some(group) = cache.get_group_by_gid(gid) {
return group.name().to_string_lossy().to_string();
}
}
#[allow(unreachable_code)]
gid.to_string()
}
pub fn parse_acl(data: &[u8]) -> Option<Vec<AclEntry>> {
if data.len() < ACL_HEADER_SIZE {
return None;
}
let version = u32::from_le_bytes(data[0..4].try_into().ok()?);
if version != ACL_VERSION {
return None;
}
let body = &data[ACL_HEADER_SIZE..];
if body.len() % ACL_ENTRY_SIZE != 0 {
return None;
}
let mut entries = Vec::new();
for chunk in body.chunks_exact(ACL_ENTRY_SIZE) {
let tag_raw = u16::from_le_bytes(chunk[0..2].try_into().ok()?);
let perm = u16::from_le_bytes(chunk[2..4].try_into().ok()?);
let id = u32::from_le_bytes(chunk[4..8].try_into().ok()?);
let tag = match tag_raw {
ACL_TAG_USER_OBJ => AclTag::UserObj,
ACL_TAG_USER => AclTag::User(id),
ACL_TAG_GROUP_OBJ => AclTag::GroupObj,
ACL_TAG_GROUP => AclTag::Group(id),
ACL_TAG_MASK => AclTag::Mask,
ACL_TAG_OTHER => AclTag::Other,
_ => continue,
};
entries.push(AclEntry { tag, permissions: perm });
}
Some(entries)
}
#[cfg(test)]
pub fn has_extended_acl(entries: &[AclEntry]) -> bool {
entries.iter().any(|e| matches!(e.tag, AclTag::User(_) | AclTag::Group(_) | AclTag::Mask))
}
pub fn format_entry(entry: &AclEntry) -> String {
let perms = format_permissions(entry.permissions);
match &entry.tag {
AclTag::UserObj => format!("user::{}", perms),
AclTag::User(uid) => format!("user:{}:{}", resolve_uid(*uid), perms),
AclTag::GroupObj => format!("group::{}", perms),
AclTag::Group(gid) => format!("group:{}:{}", resolve_gid(*gid), perms),
AclTag::Mask => format!("mask::{}", perms),
AclTag::Other => format!("other::{}", perms),
}
}
pub fn format_acl(entries: &[AclEntry]) -> String {
entries.iter().map(format_entry).collect::<Vec<_>>().join(",")
}
pub fn find_entry<'a>(entries: &'a [AclEntry], spec: &str) -> Option<&'a AclEntry> {
let parts: Vec<&str> = spec.splitn(2, ':').collect();
let tag_type = parts[0];
let qualifier = if parts.len() > 1 { parts[1] } else { "" };
match tag_type {
"user" | "u" => {
if qualifier.is_empty() {
entries.iter().find(|e| e.tag == AclTag::UserObj)
} else {
entries.iter().find(|e| match &e.tag {
AclTag::User(uid) => {
resolve_uid(*uid) == qualifier
|| uid.to_string() == qualifier
}
_ => false,
})
}
}
"group" | "g" => {
if qualifier.is_empty() {
entries.iter().find(|e| e.tag == AclTag::GroupObj)
} else {
entries.iter().find(|e| match &e.tag {
AclTag::Group(gid) => {
resolve_gid(*gid) == qualifier
|| gid.to_string() == qualifier
}
_ => false,
})
}
}
"mask" | "m" => entries.iter().find(|e| e.tag == AclTag::Mask),
"other" | "o" => entries.iter().find(|e| e.tag == AclTag::Other),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_acl_data(entries: &[(u16, u16, u32)]) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&ACL_VERSION.to_le_bytes());
for &(tag, perm, id) in entries {
data.extend_from_slice(&tag.to_le_bytes());
data.extend_from_slice(&perm.to_le_bytes());
data.extend_from_slice(&id.to_le_bytes());
}
data
}
#[test]
fn test_parse_basic_acl() {
let data = make_acl_data(&[
(ACL_TAG_USER_OBJ, 0x07, ACL_UNDEFINED_ID),
(ACL_TAG_GROUP_OBJ, 0x05, ACL_UNDEFINED_ID),
(ACL_TAG_OTHER, 0x04, ACL_UNDEFINED_ID),
]);
let entries = parse_acl(&data).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].tag, AclTag::UserObj);
assert_eq!(entries[0].permissions, 0x07);
}
#[test]
fn test_parse_extended_acl() {
let data = make_acl_data(&[
(ACL_TAG_USER_OBJ, 0x07, ACL_UNDEFINED_ID),
(ACL_TAG_USER, 0x06, 1000),
(ACL_TAG_GROUP_OBJ, 0x05, ACL_UNDEFINED_ID),
(ACL_TAG_GROUP, 0x04, 100),
(ACL_TAG_MASK, 0x07, ACL_UNDEFINED_ID),
(ACL_TAG_OTHER, 0x04, ACL_UNDEFINED_ID),
]);
let entries = parse_acl(&data).unwrap();
assert_eq!(entries.len(), 6);
assert!(has_extended_acl(&entries));
}
#[test]
fn test_no_extended_acl() {
let data = make_acl_data(&[
(ACL_TAG_USER_OBJ, 0x07, ACL_UNDEFINED_ID),
(ACL_TAG_GROUP_OBJ, 0x05, ACL_UNDEFINED_ID),
(ACL_TAG_OTHER, 0x04, ACL_UNDEFINED_ID),
]);
let entries = parse_acl(&data).unwrap();
assert!(!has_extended_acl(&entries));
}
#[test]
fn test_format_permissions() {
assert_eq!(format_permissions(0x07), "rwx");
assert_eq!(format_permissions(0x06), "rw-");
assert_eq!(format_permissions(0x05), "r-x");
assert_eq!(format_permissions(0x04), "r--");
assert_eq!(format_permissions(0x00), "---");
assert_eq!(format_permissions(0x01), "--x");
assert_eq!(format_permissions(0x02), "-w-");
assert_eq!(format_permissions(0x03), "-wx");
}
#[test]
fn test_format_entry_owner() {
let entry = AclEntry { tag: AclTag::UserObj, permissions: 0x07 };
assert_eq!(format_entry(&entry), "user::rwx");
}
#[test]
fn test_format_entry_other() {
let entry = AclEntry { tag: AclTag::Other, permissions: 0x04 };
assert_eq!(format_entry(&entry), "other::r--");
}
#[test]
fn test_format_entry_mask() {
let entry = AclEntry { tag: AclTag::Mask, permissions: 0x07 };
assert_eq!(format_entry(&entry), "mask::rwx");
}
#[test]
fn test_format_acl() {
let entries = vec![
AclEntry { tag: AclTag::UserObj, permissions: 0x07 },
AclEntry { tag: AclTag::GroupObj, permissions: 0x05 },
AclEntry { tag: AclTag::Other, permissions: 0x04 },
];
assert_eq!(format_acl(&entries), "user::rwx,group::r-x,other::r--");
}
#[test]
fn test_find_entry_by_tag() {
let entries = vec![
AclEntry { tag: AclTag::UserObj, permissions: 0x07 },
AclEntry { tag: AclTag::User(1000), permissions: 0x06 },
AclEntry { tag: AclTag::GroupObj, permissions: 0x05 },
AclEntry { tag: AclTag::Group(100), permissions: 0x04 },
AclEntry { tag: AclTag::Mask, permissions: 0x07 },
AclEntry { tag: AclTag::Other, permissions: 0x04 },
];
let e = find_entry(&entries, "user:").unwrap();
assert_eq!(e.tag, AclTag::UserObj);
let e = find_entry(&entries, "user:1000").unwrap();
assert_eq!(e.tag, AclTag::User(1000));
let e = find_entry(&entries, "group:").unwrap();
assert_eq!(e.tag, AclTag::GroupObj);
let e = find_entry(&entries, "group:100").unwrap();
assert_eq!(e.tag, AclTag::Group(100));
let e = find_entry(&entries, "mask").unwrap();
assert_eq!(e.tag, AclTag::Mask);
let e = find_entry(&entries, "other").unwrap();
assert_eq!(e.tag, AclTag::Other);
assert!(find_entry(&entries, "user:9999").is_none());
}
#[test]
fn test_parse_invalid_data() {
assert!(parse_acl(&[]).is_none());
assert!(parse_acl(&[0, 0]).is_none());
let mut data = vec![0u8; 4];
data[0] = 0x01;
assert!(parse_acl(&data).is_none());
let mut data = ACL_VERSION.to_le_bytes().to_vec();
data.push(0);
assert!(parse_acl(&data).is_none());
}
#[test]
fn test_parse_header_only() {
let data = ACL_VERSION.to_le_bytes().to_vec();
let entries = parse_acl(&data).unwrap();
assert!(entries.is_empty());
}
}
+35
View File
@@ -0,0 +1,35 @@
use std::path::PathBuf;
#[cfg(feature = "interactive")]
use directories::ProjectDirs;
#[cfg(feature = "interactive")]
const ORGANIZATION: &str = "jhspetersson";
#[cfg(feature = "interactive")]
const APPLICATION: &str = "fselect";
#[cfg(all(not(windows), feature = "interactive"))]
pub(crate) fn get_project_dir() -> Option<PathBuf> {
ProjectDirs::from("", ORGANIZATION, APPLICATION).map(|pd| pd.config_dir().to_path_buf())
}
#[cfg(all(windows, feature = "interactive"))]
pub(crate) fn get_project_dir() -> Option<PathBuf> {
ProjectDirs::from("", ORGANIZATION, APPLICATION)
.and_then(|pd| pd.config_dir().parent().map(|p| p.to_path_buf()))
}
#[cfg(not(feature = "interactive"))]
pub(crate) fn get_project_dir() -> Option<PathBuf> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_project_dir_does_not_panic() {
// Should return Some on most systems, but must never panic
let _ = get_project_dir();
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::path::Path;
use lofty::prelude::*;
use lofty::read_from_path;
/// Audio metadata and properties extracted in a single read via `lofty`.
///
/// Covers every format `lofty` understands as audio — MP3, FLAC, Ogg Vorbis,
/// Opus, M4A/AAC/ALAC, WAV, AIFF, APE, WavPack, Musepack, and Speex — so the
/// audio fields are no longer limited to MP3.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AudioInfo {
/// Duration in whole seconds.
pub duration: Option<usize>,
/// Audio bitrate in kbps.
pub bitrate: Option<u32>,
/// Sampling frequency in Hz.
pub sample_rate: Option<u32>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub comment: Option<String>,
pub year: Option<u32>,
/// Track number, formatted as `n` or `n/total`.
pub track: Option<String>,
/// Disc number ("part of a set"), formatted as `n` or `n/total`.
pub disc: Option<String>,
}
/// Whether `lofty` should read this file's audio metadata. The video MP4
/// container extensions (`mp4`, `m4v`, `3gp`) are intentionally excluded so
/// their duration keeps coming from the dedicated video extractor, while the
/// audio-only MP4 variants (`m4a`, `m4b`, ...) are handled here.
pub fn is_audio_ext(ext_lowercase: &str) -> bool {
matches!(
ext_lowercase,
"mp3" | "mp2" | "mp1"
| "flac"
| "ogg" | "oga"
| "opus"
| "m4a" | "m4b" | "m4p" | "m4r"
| "aac"
| "aiff" | "aif" | "afc" | "aifc"
| "wav" | "wave"
| "wv"
| "ape"
| "mpc" | "mp+" | "mpp"
| "spx"
)
}
/// Extract a year from a tag value: either a leading 4-digit year (possibly
/// followed by the rest of a date, e.g. `2023-05-01`) or a value that is
/// nothing but a shorter year (e.g. `800`). Longer digit runs are not years.
fn parse_year(value: &str) -> Option<u32> {
let value = value.trim();
let digit_count = value.chars().take_while(|c| c.is_ascii_digit()).count();
match digit_count {
4 => value[..4].parse().ok(),
1..=3 if digit_count == value.len() => value.parse().ok(),
_ => None,
}
}
/// Format a number paired with an optional total as `n/total`, or just `n`
/// when no total is present.
fn format_numbered(value: Option<u32>, total: Option<u32>) -> Option<String> {
value.map(|v| match total {
Some(t) => format!("{}/{}", v, t),
None => v.to_string(),
})
}
/// Read audio metadata and properties for a supported audio file, or `None`
/// when the extension is not a recognized audio format or the file cannot be
/// parsed.
pub fn get_audio_info(path: &Path) -> Option<AudioInfo> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
if !is_audio_ext(&ext) {
return None;
}
let tagged_file = read_from_path(path).ok()?;
let properties = tagged_file.properties();
let duration = properties.duration();
let mut info = AudioInfo {
// An exactly-zero duration means lofty could not determine it;
// surface that as an absent value rather than a misleading 0. A
// sub-second clip still reports 0 whole seconds.
duration: (!duration.is_zero()).then_some(duration.as_secs() as usize),
bitrate: properties.audio_bitrate().or_else(|| properties.overall_bitrate()),
sample_rate: properties.sample_rate(),
..Default::default()
};
if let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) {
info.title = tag.title().map(|c| c.to_string());
info.artist = tag.artist().map(|c| c.to_string());
info.album = tag.album().map(|c| c.to_string());
info.genre = tag.genre().map(|c| c.to_string());
info.comment = tag.comment().map(|c| c.to_string());
info.year = tag
.get_string(ItemKey::Year)
.or_else(|| tag.get_string(ItemKey::RecordingDate))
.and_then(parse_year);
info.track = format_numbered(tag.track(), tag.track_total());
info.disc = format_numbered(tag.disk(), tag.disk_total());
}
Some(info)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources/test/audio")
.join(name)
}
#[test]
fn test_is_audio_ext_excludes_video_mp4() {
assert!(is_audio_ext("flac"));
assert!(is_audio_ext("m4a"));
assert!(is_audio_ext("mp3"));
assert!(!is_audio_ext("mp4"));
assert!(!is_audio_ext("m4v"));
assert!(!is_audio_ext("mkv"));
assert!(!is_audio_ext("txt"));
}
#[test]
fn test_parse_year() {
assert_eq!(parse_year("2023"), Some(2023));
assert_eq!(parse_year("2023-05-01"), Some(2023));
assert_eq!(parse_year(" 2023 "), Some(2023));
assert_eq!(parse_year("800"), Some(800));
assert_eq!(parse_year("20231"), None);
assert_eq!(parse_year("05/01/2023"), None);
assert_eq!(parse_year("unknown"), None);
assert_eq!(parse_year(""), None);
}
#[test]
fn test_format_numbered() {
assert_eq!(format_numbered(Some(4), Some(9)), Some(String::from("4/9")));
assert_eq!(format_numbered(Some(4), None), Some(String::from("4")));
assert_eq!(format_numbered(None, Some(9)), None);
}
#[test]
fn test_get_audio_info_mp3_duration() {
let info = get_audio_info(&fixture("silent-35s.mp3")).expect("mp3 should parse");
assert_eq!(info.duration, Some(35));
}
#[test]
fn test_get_audio_info_wav_duration() {
let info = get_audio_info(&fixture("silent.wav")).expect("wav should parse");
assert_eq!(info.duration, Some(15));
}
#[test]
fn test_get_audio_info_rejects_non_audio() {
assert!(get_audio_info(Path::new("nonexistent.txt")).is_none());
}
}
+217
View File
@@ -0,0 +1,217 @@
macro_rules! check_cap {
($cap_name: ident, $code: expr, $permitted: ident, $inherited: ident, $effective: ident, $result: ident) => {
if let Some(str_result) = check_capability($permitted, $inherited, 1 << $code) {
$result.push(stringify!($cap_name).to_owned() + "=" + &$effective + &str_result);
}
};
}
macro_rules! check_caps_word_0 {
($permitted: ident, $inherited: ident, $effective: ident, $result: ident) => {
check_cap!(cap_chown, 0, $permitted, $inherited, $effective, $result);
check_cap!(cap_dac_override, 1, $permitted, $inherited, $effective, $result);
check_cap!(cap_dac_read_search, 2, $permitted, $inherited, $effective, $result);
check_cap!(cap_fowner, 3, $permitted, $inherited, $effective, $result);
check_cap!(cap_fsetid, 4, $permitted, $inherited, $effective, $result);
check_cap!(cap_kill, 5, $permitted, $inherited, $effective, $result);
check_cap!(cap_setgid, 6, $permitted, $inherited, $effective, $result);
check_cap!(cap_setuid, 7, $permitted, $inherited, $effective, $result);
check_cap!(cap_setpcap, 8, $permitted, $inherited, $effective, $result);
check_cap!(cap_linux_immutable, 9, $permitted, $inherited, $effective, $result);
check_cap!(cap_net_bind_service, 10, $permitted, $inherited, $effective, $result);
check_cap!(cap_net_broadcast, 11, $permitted, $inherited, $effective, $result);
check_cap!(cap_net_admin, 12, $permitted, $inherited, $effective, $result);
check_cap!(cap_net_raw, 13, $permitted, $inherited, $effective, $result);
check_cap!(cap_ipc_lock, 14, $permitted, $inherited, $effective, $result);
check_cap!(cap_ipc_owner, 15, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_module, 16, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_rawio, 17, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_chroot, 18, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_ptrace, 19, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_pacct, 20, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_admin, 21, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_boot, 22, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_nice, 23, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_resource, 24, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_time, 25, $permitted, $inherited, $effective, $result);
check_cap!(cap_sys_tty_config, 26, $permitted, $inherited, $effective, $result);
check_cap!(cap_mknod, 27, $permitted, $inherited, $effective, $result);
check_cap!(cap_lease, 28, $permitted, $inherited, $effective, $result);
check_cap!(cap_audit_write, 29, $permitted, $inherited, $effective, $result);
check_cap!(cap_audit_control, 30, $permitted, $inherited, $effective, $result);
check_cap!(cap_setfcap, 31, $permitted, $inherited, $effective, $result);
};
}
macro_rules! check_caps_word_1 {
($permitted: ident, $inherited: ident, $effective: ident, $result: ident) => {
check_cap!(cap_mac_override, 0, $permitted, $inherited, $effective, $result);
check_cap!(cap_mac_admin, 1, $permitted, $inherited, $effective, $result);
check_cap!(cap_syslog, 2, $permitted, $inherited, $effective, $result);
check_cap!(cap_wake_alarm, 3, $permitted, $inherited, $effective, $result);
check_cap!(cap_block_suspend, 4, $permitted, $inherited, $effective, $result);
check_cap!(cap_audit_read, 5, $permitted, $inherited, $effective, $result);
check_cap!(cap_perfmon, 6, $permitted, $inherited, $effective, $result);
check_cap!(cap_bpf, 7, $permitted, $inherited, $effective, $result);
check_cap!(cap_checkpoint_restore, 8, $permitted, $inherited, $effective, $result);
};
}
const VFS_CAP_REVISION_MASK: u32 = 0xFF000000;
const VFS_CAP_REVISION_1: u32 = 0x01000000;
const VFS_CAP_REVISION_2: u32 = 0x02000000;
const VFS_CAP_REVISION_3: u32 = 0x03000000;
const VFS_CAP_FLAGS_EFFECTIVE: u32 = 0x000001;
const XATTR_CAPS_SZ_1: usize = 12; // 4 (magic) + 4 (permitted) + 4 (inherited)
const XATTR_CAPS_SZ_2: usize = 20; // 4 (magic) + 2 * (4 (permitted) + 4 (inherited))
const XATTR_CAPS_SZ_3: usize = 24; // v2 + 4 (rootid)
pub fn parse_capabilities(caps: Vec<u8>) -> String {
if caps.len() < 4 {
return String::new();
}
let magic_etc = u32::from_le_bytes(caps[0..4].try_into().unwrap());
let revision = magic_etc & VFS_CAP_REVISION_MASK;
let effective = if magic_etc & VFS_CAP_FLAGS_EFFECTIVE != 0 {
String::from("e")
} else {
String::new()
};
let mut result: Vec<String> = vec![];
match revision {
VFS_CAP_REVISION_1 => {
if caps.len() < XATTR_CAPS_SZ_1 {
return String::new();
}
let permitted = u32::from_le_bytes(caps[4..8].try_into().unwrap());
let inherited = u32::from_le_bytes(caps[8..12].try_into().unwrap());
check_caps_word_0!(permitted, inherited, effective, result);
}
VFS_CAP_REVISION_2 | VFS_CAP_REVISION_3 => {
// v2 (20 bytes) and v3 (24 bytes with rootid) share the data layout
if caps.len() < XATTR_CAPS_SZ_2 {
return String::new();
}
let permitted = u32::from_le_bytes(caps[4..8].try_into().unwrap());
let inherited = u32::from_le_bytes(caps[8..12].try_into().unwrap());
check_caps_word_0!(permitted, inherited, effective, result);
let permitted = u32::from_le_bytes(caps[12..16].try_into().unwrap());
let inherited = u32::from_le_bytes(caps[16..20].try_into().unwrap());
check_caps_word_1!(permitted, inherited, effective, result);
// v3 has a rootid (namespace owner UID) appended
if caps.len() >= XATTR_CAPS_SZ_3 {
let rootid = u32::from_le_bytes(caps[20..24].try_into().unwrap());
if rootid != 0 {
result.push(format!("[rootid={}]", rootid));
}
}
}
_ => return String::new(),
}
result.join(" ")
}
fn check_capability(perm: u32, inh: u32, cap: u32) -> Option<String> {
if inh & cap == cap && perm & cap == cap {
Some(String::from("ip"))
} else if perm & cap == cap {
Some(String::from("p"))
} else if inh & cap == cap {
Some(String::from("i"))
} else {
None
}
}
/// Check if a capabilities string contains a specific capability by exact name match.
/// The capabilities string is space-separated entries like "cap_net_bind_service=ep cap_net_admin=ep".
pub fn has_capability(caps_string: &str, cap_name: &str) -> bool {
caps_string.split_whitespace().any(|entry| {
match entry.find('=') {
Some(idx) => &entry[..idx] == cap_name,
None => entry == cap_name,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn v2_blob(magic_flags: u32, permitted0: u32, inherited0: u32) -> Vec<u8> {
let mut blob = Vec::new();
blob.extend_from_slice(&(VFS_CAP_REVISION_2 | magic_flags).to_le_bytes());
blob.extend_from_slice(&permitted0.to_le_bytes());
blob.extend_from_slice(&inherited0.to_le_bytes());
blob.extend_from_slice(&0u32.to_le_bytes());
blob.extend_from_slice(&0u32.to_le_bytes());
blob
}
#[test]
fn test_parse_v2_blob_as_written_by_setcap() {
// `setcap cap_net_bind_service=ep` writes a v2 blob whose magic is
// 0x02000000 | effective flag; parsing must not come back empty.
let blob = v2_blob(VFS_CAP_FLAGS_EFFECTIVE, 1 << 10, 0);
assert_eq!(parse_capabilities(blob), "cap_net_bind_service=ep");
}
#[test]
fn test_parse_v3_blob_with_rootid() {
let mut blob = Vec::new();
blob.extend_from_slice(&(VFS_CAP_REVISION_3 | VFS_CAP_FLAGS_EFFECTIVE).to_le_bytes());
blob.extend_from_slice(&(1u32 << 10).to_le_bytes());
blob.extend_from_slice(&0u32.to_le_bytes());
blob.extend_from_slice(&0u32.to_le_bytes());
blob.extend_from_slice(&0u32.to_le_bytes());
blob.extend_from_slice(&1000u32.to_le_bytes());
assert_eq!(
parse_capabilities(blob),
"cap_net_bind_service=ep [rootid=1000]"
);
}
#[test]
fn test_parse_v2_blob_without_effective_flag() {
let blob = v2_blob(0, 1 << 21, 0);
assert_eq!(parse_capabilities(blob), "cap_sys_admin=p");
}
#[test]
fn test_has_capability_exact_match() {
let caps = "cap_net_bind_service=ep cap_net_admin=ep";
assert!(has_capability(caps, "cap_net_bind_service"));
assert!(has_capability(caps, "cap_net_admin"));
}
#[test]
fn test_has_capability_no_substring_match() {
let caps = "cap_net_bind_service=ep cap_net_admin=ep";
// Should NOT match via substring
assert!(!has_capability(caps, "cap_net"));
assert!(!has_capability(caps, "cap_net_bind"));
}
#[test]
fn test_has_capability_empty() {
assert!(!has_capability("", "cap_net_admin"));
}
#[test]
fn test_has_capability_single() {
assert!(has_capability("cap_sys_admin=ep", "cap_sys_admin"));
assert!(!has_capability("cap_sys_admin=ep", "cap_sys"));
}
}
+405
View File
@@ -0,0 +1,405 @@
use std::sync::{LazyLock, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Utc};
use chrono_english::{parse_date_string, Dialect};
use regex::Regex;
static DATE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new("^(\\d{4})(-|:)(\\d{1,2})(-|:)(\\d{1,2})(?: (\\d{1,2})(?::(\\d{1,2})(?::(\\d{1,2}))?)?)?$").unwrap()
});
static US_DATES: Mutex<bool> = Mutex::new(false);
pub fn set_us_dates(us: bool) {
*US_DATES.lock().unwrap() = us;
}
pub fn parse_datetime(s: &str) -> Result<(NaiveDateTime, NaiveDateTime), String> {
if s == "today" {
let date = Local::now().date_naive();
let start = date.and_hms_opt(0, 0, 0).unwrap();
let finish = date.and_hms_opt(23, 59, 59).unwrap();
return Ok((start, finish));
}
if s == "yesterday" {
let date = Local::now().date_naive() - Duration::try_days(1).unwrap();
let start = date.and_hms_opt(0, 0, 0).unwrap();
let finish = date.and_hms_opt(23, 59, 59).unwrap();
return Ok((start, finish));
}
match DATE_REGEX.captures(s) {
Some(cap) => {
// Ensure consistent separator (both dashes or both colons)
if cap[2] != cap[4] {
return Err("Error parsing date/time value: ".to_string() + s);
}
let year: i32 = cap[1].parse().unwrap();
let month: u32 = cap[3].parse().unwrap();
let day: u32 = cap[5].parse().unwrap();
let hour_start: u32;
let hour_finish: u32;
match cap.get(6) {
Some(val) => {
hour_start = val.as_str().parse().unwrap();
hour_finish = hour_start;
}
None => {
hour_start = 0;
hour_finish = 23;
}
}
let min_start: u32;
let min_finish: u32;
match cap.get(7) {
Some(val) => {
min_start = val.as_str().parse().unwrap();
min_finish = min_start;
}
None => {
min_start = 0;
min_finish = 59;
}
}
let sec_start: u32;
let sec_finish: u32;
match cap.get(8) {
Some(val) => {
sec_start = val.as_str().parse().unwrap();
sec_finish = sec_start;
}
None => {
sec_start = 0;
sec_finish = 59;
}
}
// Build the naive datetime directly: a Local round-trip would fail on
// DST-skipped or ambiguous midnights while adding nothing to the result
match NaiveDate::from_ymd_opt(year, month, day) {
Some(date) => {
let start = date.and_hms_opt(hour_start, min_start, sec_start);
let finish = date.and_hms_opt(hour_finish, min_finish, sec_finish);
match (start, finish) {
(Some(s), Some(f)) => Ok((s, f)),
_ => Err("Error parsing date/time value: ".to_string() + s),
}
}
None => Err("Error parsing date/time value: ".to_string() + s),
}
}
None => {
// Simplified relative-day syntax: any length of `+N`/`-N` counts
// as days from today; longer non-numeric strings fall through to
// the natural-language parser.
if s.len() >= 2
&& (s.starts_with("+") || s.starts_with("-"))
&& let Ok(days) = s.parse::<i64>()
{
let date = Local::now().date_naive() + Duration::days(days);
let start = date.and_hms_opt(0, 0, 0).unwrap();
let finish = date.and_hms_opt(23, 59, 59).unwrap();
Ok((start, finish))
} else if s.len() >= 5 {
let dialect = match *US_DATES.lock().unwrap() {
true => Dialect::Us,
false => Dialect::Uk,
};
match parse_date_string(s, Local::now(), dialect) {
Ok(date_time) => {
let date_time = date_time.naive_local();
let finish = if date_time.hour() == 0
&& date_time.minute() == 0
&& date_time.second() == 0
{
date_time
.with_hour(23)
.unwrap()
.with_minute(59)
.unwrap()
.with_second(59)
.unwrap()
} else {
date_time
};
Ok((date_time, finish))
}
_ => Err("Error parsing date/time value: ".to_string() + s),
}
} else {
Err("Error parsing date/time value: ".to_string() + s)
}
}
}
}
pub fn system_time_to_naive_local(sdt: SystemTime) -> Option<NaiveDateTime> {
let (sec, nsec) = match sdt.duration_since(UNIX_EPOCH) {
Ok(dur) => (i64::try_from(dur.as_secs()).ok()?, dur.subsec_nanos()),
Err(e) => {
let dur = e.duration();
let secs = i64::try_from(dur.as_secs()).ok()?;
if dur.subsec_nanos() == 0 {
(secs.checked_neg()?, 0)
} else {
(secs.checked_neg()?.checked_sub(1)?, 1_000_000_000 - dur.subsec_nanos())
}
}
};
DateTime::<Utc>::from_timestamp(sec, nsec)
.map(|dt_utc| dt_utc.with_timezone(&Local).naive_local())
}
pub fn to_local_datetime(dt: &zip::DateTime) -> NaiveDateTime {
let date = NaiveDate::from_ymd_opt(dt.year() as i32, dt.month() as u32, dt.day() as u32)
.or_else(|| NaiveDate::from_ymd_opt(dt.year() as i32, 1, 1))
.unwrap_or_default();
let time = NaiveTime::from_hms_opt(dt.hour() as u32, dt.minute() as u32, dt.second() as u32)
.unwrap_or_default();
NaiveDateTime::new(date, time)
}
pub fn format_datetime(dt: &NaiveDateTime) -> String {
format!("{}", dt.format("%Y-%m-%d %H:%M:%S"))
}
pub fn format_date(date: &NaiveDate) -> String {
format!("{}", date.format("%Y-%m-%d"))
}
pub fn format_time(time: &NaiveTime) -> String {
format!("{}", time.format("%H:%M:%S"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration as StdDuration;
use chrono::{Datelike, Local, NaiveDate};
#[test]
fn test_parse_today() {
let result = parse_datetime("today").unwrap();
let now = Local::now().date_naive();
let start = now.and_hms_opt(0, 0, 0).unwrap();
let finish = now.and_hms_opt(23, 59, 59).unwrap();
assert_eq!(result.0, start);
assert_eq!(result.1, finish);
}
#[test]
fn test_parse_yesterday() {
let result = parse_datetime("yesterday").unwrap();
let yesterday = Local::now().date_naive() - chrono::Duration::days(1);
let start = yesterday.and_hms_opt(0, 0, 0).unwrap();
let finish = yesterday.and_hms_opt(23, 59, 59).unwrap();
assert_eq!(result.0, start);
assert_eq!(result.1, finish);
}
#[test]
fn test_parse_two_days_ago() {
let result = parse_datetime("2 days ago 00:00").unwrap();
let two_days_ago = Local::now().date_naive() - chrono::Duration::days(2);
let start = two_days_ago.and_hms_opt(0, 0, 0).unwrap();
let finish = two_days_ago.and_hms_opt(23, 59, 59).unwrap();
assert_eq!(result.0, start);
assert_eq!(result.1, finish);
}
#[test]
fn test_parse_two_days_ago_simplified() {
let result = parse_datetime("-2").unwrap();
let two_days_ago = Local::now().date_naive() - chrono::Duration::days(2);
let start = two_days_ago.and_hms_opt(0, 0, 0).unwrap();
let finish = two_days_ago.and_hms_opt(23, 59, 59).unwrap();
assert_eq!(result.0, start);
assert_eq!(result.1, finish);
}
#[test]
fn test_parse_specific_date() {
let result = parse_datetime("2023-12-11").unwrap();
let date = NaiveDate::from_ymd_opt(2023, 12, 11).unwrap();
let start = date.and_hms_opt(0, 0, 0).unwrap();
let finish = date.and_hms_opt(23, 59, 59).unwrap();
assert_eq!(result.0, start);
assert_eq!(result.1, finish);
}
#[test]
fn test_parse_specific_datetime() {
let result = parse_datetime("2023-12-11 14:30:45").unwrap();
let date = NaiveDate::from_ymd_opt(2023, 12, 11).unwrap();
let start = date.and_hms_opt(14, 30, 45).unwrap();
let finish = start;
assert_eq!(result.0, start);
assert_eq!(result.1, finish);
}
#[test]
fn test_invalid_format() {
let result = parse_datetime("invalid-date");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Error parsing date/time value: invalid-date");
}
#[test]
fn test_parse_invalid_calendar_date() {
// Genuinely invalid dates must still error after dropping the Local round-trip
assert!(parse_datetime("2023-02-30").is_err());
assert!(parse_datetime("2023-13-01").is_err());
assert!(parse_datetime("2023-04-31").is_err());
}
#[test]
fn test_parse_out_of_range_time() {
assert!(parse_datetime("2024-01-01 25:00:00").is_err());
assert!(parse_datetime("2024-01-01 12:61:00").is_err());
assert!(parse_datetime("2024-01-01 12:00:99").is_err());
}
#[test]
fn test_parse_non_numeric_plus_minus() {
assert!(parse_datetime("+abc").is_err());
assert!(parse_datetime("-xyz").is_err());
}
#[test]
fn test_to_local_datetime_feb() {
// Regression: previously panicked when current day > days in target month
let dt = zip::DateTime::from_date_and_time(2023, 2, 15, 10, 30, 0).unwrap();
let result = to_local_datetime(&dt);
assert_eq!(result.year(), 2023);
assert_eq!(result.month(), 2);
assert_eq!(result.day(), 15);
assert_eq!(result.hour(), 10);
assert_eq!(result.minute(), 30);
}
#[test]
fn test_partial_date_parsing() {
let result = parse_datetime("2023-12-11 14:30").unwrap();
let date = NaiveDate::from_ymd_opt(2023, 12, 11).unwrap();
let start = date.and_hms_opt(14, 30, 0).unwrap();
let finish = date.and_hms_opt(14, 30, 59).unwrap();
assert_eq!(result.0, start);
assert_eq!(result.1, finish);
}
#[test]
fn test_substring_date_should_not_match() {
// Date regex should not match dates embedded in other text
assert!(parse_datetime("abc2024-01-15xyz").is_err());
}
#[test]
fn test_mixed_separators_should_not_match() {
// Mixed dash and colon separators should not be accepted
assert!(parse_datetime("2024-01:15").is_err());
}
#[test]
fn test_colon_separator_date_accepted() {
// EXIF-style colon-separated dates should work
let result = parse_datetime("2024:01:15");
assert!(result.is_ok());
}
#[test]
fn test_system_time_to_naive_local_unix_epoch() {
let result = system_time_to_naive_local(UNIX_EPOCH);
assert!(result.is_some(), "UNIX_EPOCH must convert successfully");
let expected = DateTime::<Utc>::from_timestamp(0, 0)
.unwrap()
.with_timezone(&Local)
.naive_local();
assert_eq!(result.unwrap(), expected);
}
#[test]
fn test_system_time_to_naive_local_now_roundtrip() {
let now = SystemTime::now();
let result = system_time_to_naive_local(now);
assert!(result.is_some(), "SystemTime::now() must convert");
let now_local = Local::now().naive_local();
let diff = (now_local - result.unwrap()).num_seconds().abs();
assert!(diff < 5, "round-trip drift should be tiny, got {}s", diff);
}
#[test]
fn test_system_time_to_naive_local_before_epoch() {
let one_sec_before = UNIX_EPOCH - StdDuration::from_secs(1);
let result = system_time_to_naive_local(one_sec_before);
assert!(result.is_some(), "pre-epoch SystemTime must convert");
let expected = DateTime::<Utc>::from_timestamp(-1, 0)
.unwrap()
.with_timezone(&Local)
.naive_local();
assert_eq!(result.unwrap(), expected);
}
#[test]
fn test_system_time_to_naive_local_before_epoch_with_nanos() {
let pre_epoch = UNIX_EPOCH - StdDuration::new(1, 0) + StdDuration::from_millis(500);
let result = system_time_to_naive_local(pre_epoch);
assert!(result.is_some(), "pre-epoch SystemTime with nanos must convert");
}
#[test]
fn test_system_time_to_naive_local_far_future_does_not_panic() {
let huge = StdDuration::from_secs(u64::MAX / 2);
if let Some(t) = SystemTime::now().checked_add(huge) {
let _ = system_time_to_naive_local(t);
}
}
#[test]
fn test_system_time_to_naive_local_max_does_not_panic() {
let mut times = vec![];
if let Some(t) = SystemTime::now().checked_add(StdDuration::from_secs(i64::MAX as u64 - 1)) {
times.push(t);
}
if let Some(t) = UNIX_EPOCH.checked_add(StdDuration::from_secs(100_000_000_000_000)) {
times.push(t);
}
for t in times {
let _ = system_time_to_naive_local(t);
}
}
#[test]
fn test_system_time_to_naive_local_wrapped_seconds_returns_none() {
let beyond_i64 = StdDuration::from_secs(u64::MAX - 10);
if let Some(t) = UNIX_EPOCH.checked_add(beyond_i64) {
let result = system_time_to_naive_local(t);
assert!(
result.is_none(),
"values past i64::MAX seconds must yield None, got {:?}",
result
);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
use std::io;
use std::path::Path;
use imagesize::ImageError;
use crate::util::dimensions::DimensionsExtractor;
use crate::util::Dimensions;
pub struct ImageDimensionsExtractor;
impl ImageDimensionsExtractor {
const EXTENSIONS: [&'static str; 13] = [
"bmp", "gif", "heic", "heif", "jpeg", "jpg", "jxl", "png", "psb", "psd", "tga", "tiff",
"webp",
];
}
impl DimensionsExtractor for ImageDimensionsExtractor {
fn supports_ext(&self, ext_lowercase: &str) -> bool {
ImageDimensionsExtractor::EXTENSIONS.contains(&ext_lowercase)
}
fn try_read_dimensions(&self, path: &Path) -> io::Result<Option<Dimensions>> {
let dimensions = imagesize::size(path).map_err(|err| match err {
ImageError::NotSupported => {
io::Error::new(io::ErrorKind::InvalidInput, ImageError::NotSupported)
}
ImageError::CorruptedImage => {
io::Error::new(io::ErrorKind::InvalidData, ImageError::CorruptedImage)
}
ImageError::IoError(e) => e,
})?;
Ok(Some(Dimensions {
width: dimensions.width,
height: dimensions.height,
}))
}
}
#[cfg(test)]
mod test {
use super::ImageDimensionsExtractor;
use crate::util::dimensions::{test::test_successful, Dimensions};
use std::error::Error;
fn do_test_success(ext: &str, w: usize, h: usize) -> Result<(), Box<dyn Error>> {
let res_path = String::from("image/rust-logo-blk.") + ext;
test_successful(
ImageDimensionsExtractor,
&res_path,
Some(Dimensions {
width: w,
height: h,
}),
)
}
#[test]
pub fn test_bmp() -> Result<(), Box<dyn Error>> {
do_test_success("bmp", 144, 144)
}
#[test]
pub fn test_gif() -> Result<(), Box<dyn Error>> {
do_test_success("gif", 144, 144)
}
#[test]
pub fn test_jpeg() -> Result<(), Box<dyn Error>> {
do_test_success("jpeg", 144, 144)
}
#[test]
pub fn test_jpg() -> Result<(), Box<dyn Error>> {
do_test_success("jpg", 144, 144)
}
#[test]
pub fn test_png() -> Result<(), Box<dyn Error>> {
do_test_success("png", 144, 144)
}
#[test]
pub fn test_tiff() -> Result<(), Box<dyn Error>> {
do_test_success("tiff", 144, 144)
}
#[test]
pub fn test_webp() -> Result<(), Box<dyn Error>> {
do_test_success("webp", 144, 144)
}
}
+58
View File
@@ -0,0 +1,58 @@
use std::fs::File;
use std::io;
use std::path::Path;
use matroska::MatroskaError;
use crate::util::dimensions::DimensionsExtractor;
use crate::util::Dimensions;
pub struct MkvDimensionsExtractor;
impl DimensionsExtractor for MkvDimensionsExtractor {
fn supports_ext(&self, ext_lowercase: &str) -> bool {
"mkv" == ext_lowercase || "webm" == ext_lowercase
}
fn try_read_dimensions(&self, path: &Path) -> io::Result<Option<Dimensions>> {
let fd = File::open(path)?;
let matroska = matroska::Matroska::open(fd).map_err(|err| match err {
MatroskaError::Io(io) => io,
MatroskaError::UTF8(utf8) => io::Error::new(io::ErrorKind::InvalidData, utf8),
e => io::Error::new(io::ErrorKind::InvalidData, e),
})?;
Ok(matroska
.tracks
.iter()
.find(|&track| track.tracktype == matroska::Tracktype::Video)
.and_then(|track| {
if let matroska::Settings::Video(settings) = &track.settings {
Some(Dimensions {
width: settings.pixel_width as usize,
height: settings.pixel_height as usize,
})
} else {
None
}
}))
}
}
#[cfg(test)]
mod test {
use super::MkvDimensionsExtractor;
use crate::util::dimensions::{test::test_successful, Dimensions};
use std::error::Error;
#[test]
fn test_success() -> Result<(), Box<dyn Error>> {
test_successful(
MkvDimensionsExtractor,
"video/rust-logo-blk.mkv",
Some(Dimensions {
width: 144,
height: 144,
}),
)
}
}
+76
View File
@@ -0,0 +1,76 @@
use std::io;
mod image;
mod mkv;
mod mp4;
mod svg;
use self::svg::SvgDimensionsExtractor;
use image::ImageDimensionsExtractor;
use mkv::MkvDimensionsExtractor;
use mp4::Mp4DimensionsExtractor;
use std::path::Path;
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct Dimensions {
pub width: usize,
pub height: usize,
}
pub trait DimensionsExtractor {
fn supports_ext(&self, ext_lowercase: &str) -> bool;
fn try_read_dimensions(&self, path: &Path) -> io::Result<Option<Dimensions>>;
}
const EXTRACTORS: [&dyn DimensionsExtractor; 4] = [
&MkvDimensionsExtractor,
&Mp4DimensionsExtractor,
&SvgDimensionsExtractor,
&ImageDimensionsExtractor,
];
pub fn get_dimensions<T: AsRef<Path>>(path: T) -> Option<Dimensions> {
let path_ref = path.as_ref();
let extension = path_ref.extension()?.to_str()?;
EXTRACTORS
.iter()
.find(|extractor| extractor.supports_ext(&extension.to_lowercase()))
.and_then(|extractor| extractor.try_read_dimensions(path_ref).unwrap_or_default())
}
#[cfg(test)]
mod test {
use crate::util::dimensions::DimensionsExtractor;
use crate::util::Dimensions;
use std::error::Error;
use std::ffi::OsStr;
use std::path::PathBuf;
pub(crate) fn test_successful<T: DimensionsExtractor>(
under_test: T,
test_res_path: &str,
expected: Option<Dimensions>,
) -> Result<(), Box<dyn Error>> {
let path_string = std::env::var("CARGO_MANIFEST_DIR")? + "/resources/test/" + test_res_path;
let path = PathBuf::from(path_string);
assert!(under_test.supports_ext(path.extension().and_then(OsStr::to_str).unwrap()));
assert_eq!(under_test.try_read_dimensions(&path)?, expected);
Ok(())
}
pub(crate) fn test_fail<T: DimensionsExtractor>(
under_test: T,
test_res_path: &str,
expected: std::io::ErrorKind,
) -> Result<(), Box<dyn Error>> {
let path_string = std::env::var("CARGO_MANIFEST_DIR")? + "/resources/test/" + test_res_path;
let path = PathBuf::from(path_string);
assert!(under_test.supports_ext(path.extension().and_then(OsStr::to_str).unwrap()));
let result = under_test.try_read_dimensions(&path);
assert_eq!(result.map_err(|err| err.kind()), Err(expected));
Ok(())
}
}
+51
View File
@@ -0,0 +1,51 @@
use std::fs::File;
use std::io;
use std::io::BufReader;
use std::path::Path;
use crate::util::dimensions::DimensionsExtractor;
use crate::util::Dimensions;
pub struct Mp4DimensionsExtractor;
impl DimensionsExtractor for Mp4DimensionsExtractor {
fn supports_ext(&self, ext_lowercase: &str) -> bool {
// All ISO-BMFF containers mp4parse reads.
matches!(ext_lowercase, "mp4" | "m4v" | "mov" | "3gp")
}
fn try_read_dimensions(&self, path: &Path) -> io::Result<Option<Dimensions>> {
let fd = File::open(path)?;
let mut reader = BufReader::new(fd);
let context = mp4parse::read_mp4(&mut reader)?;
Ok(context
.tracks
.iter()
.find(|track| track.track_type == mp4parse::TrackType::Video)
.and_then(|track| {
track.tkhd.as_ref().map(|tkhd| Dimensions {
width: (tkhd.width / 65536) as usize,
height: (tkhd.height / 65536) as usize,
})
}))
}
}
#[cfg(test)]
mod test {
use super::Mp4DimensionsExtractor;
use crate::util::dimensions::{test::test_successful, Dimensions};
use std::error::Error;
#[test]
fn test_success() -> Result<(), Box<dyn Error>> {
test_successful(
Mp4DimensionsExtractor,
"video/rust-logo-blk.mp4",
Some(Dimensions {
width: 144,
height: 144,
}),
)
}
}
+187
View File
@@ -0,0 +1,187 @@
use std::io;
use std::path::Path;
use svg::node::element::tag::SVG;
use svg::parser::Event;
use crate::util::dimensions::DimensionsExtractor;
use crate::util::Dimensions;
pub struct SvgDimensionsExtractor;
impl SvgDimensionsExtractor {}
/// Parse an SVG length attribute: a number with an optional unit suffix
/// (`144`, `144.5`, `144px`, `10cm`). Percentages are relative and have no
/// absolute pixel value, so they yield `Ok(None)`; a value without a leading
/// number is invalid data.
fn parse_svg_length(value: &str) -> io::Result<Option<usize>> {
let value = value.trim();
let numeric_len = value
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(value.len());
let (number, unit) = value.split_at(numeric_len);
if unit.trim() == "%" {
return Ok(None);
}
match number.parse::<f64>() {
Ok(v) if v.is_finite() => Ok(Some(v.round() as usize)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid SVG length: {}", value),
)),
}
}
/// Parse a `viewBox` attribute (`min-x min-y width height`, separated by
/// whitespace and/or commas) into its width/height part.
fn parse_view_box(value: &str) -> Option<Dimensions> {
let mut parts = value
.split(|c: char| c.is_ascii_whitespace() || c == ',')
.filter(|p| !p.is_empty());
let _min_x = parts.next()?;
let _min_y = parts.next()?;
let width = parts.next()?.parse::<f64>().ok()?;
let height = parts.next()?.parse::<f64>().ok()?;
if width.is_finite() && height.is_finite() && width >= 0.0 && height >= 0.0 {
Some(Dimensions {
width: width.round() as usize,
height: height.round() as usize,
})
} else {
None
}
}
impl DimensionsExtractor for SvgDimensionsExtractor {
fn supports_ext(&self, ext_lowercase: &str) -> bool {
"svg" == ext_lowercase
}
fn try_read_dimensions(&self, path: &Path) -> io::Result<Option<Dimensions>> {
let mut content = String::new();
for event in svg::open(path, &mut content)? {
if let Event::Tag(SVG, _, attributes) = event {
if let (Some(width_value), Some(height_value)) =
(attributes.get("width"), attributes.get("height"))
&& let (Some(width), Some(height)) =
(parse_svg_length(width_value)?, parse_svg_length(height_value)?)
{
return Ok(Some(Dimensions { width, height }));
}
// No absolute width/height (the standard shape of viewBox-only
// exports from Figma/Illustrator/icon sets, or percentage
// sizes): fall back to the viewBox dimensions.
return Ok(attributes.get("viewBox").and_then(|v| parse_view_box(v)));
}
}
Ok(None)
}
}
#[cfg(test)]
mod test {
use super::SvgDimensionsExtractor;
use crate::util::dimensions::{test::test_fail, test::test_successful, Dimensions};
use std::error::Error;
use std::io;
#[test]
fn test_success() -> Result<(), Box<dyn Error>> {
test_successful(
SvgDimensionsExtractor,
"image/rust-logo-blk.svg",
Some(Dimensions {
width: 144,
height: 144,
}),
)
}
#[test]
fn test_non_square() -> Result<(), Box<dyn Error>> {
test_successful(
SvgDimensionsExtractor,
"image/rect.svg",
Some(Dimensions {
width: 200,
height: 100,
}),
)
}
#[test]
fn test_nonexistent_returns_error_not_panic() {
use crate::util::dimensions::DimensionsExtractor;
let extractor = SvgDimensionsExtractor;
let result = extractor.try_read_dimensions(std::path::Path::new("/nonexistent/file.svg"));
assert!(result.is_err());
}
#[test]
fn test_length_units_and_floats() {
use super::parse_svg_length;
assert_eq!(parse_svg_length("144").unwrap(), Some(144));
assert_eq!(parse_svg_length("144px").unwrap(), Some(144));
assert_eq!(parse_svg_length("144.4").unwrap(), Some(144));
assert_eq!(parse_svg_length(" 10cm ").unwrap(), Some(10));
assert_eq!(parse_svg_length("100%").unwrap(), None);
assert!(parse_svg_length("bar").is_err());
assert!(parse_svg_length("").is_err());
}
#[test]
fn test_view_box_parsing() {
use super::parse_view_box;
use crate::util::Dimensions;
assert_eq!(
parse_view_box("0 0 100 50"),
Some(Dimensions { width: 100, height: 50 })
);
assert_eq!(
parse_view_box("0, 0, 24, 24"),
Some(Dimensions { width: 24, height: 24 })
);
assert_eq!(
parse_view_box("-10 -10 36.5 20"),
Some(Dimensions { width: 37, height: 20 })
);
assert_eq!(parse_view_box("0 0 100"), None);
assert_eq!(parse_view_box("0 0 -1 5"), None);
}
#[test]
fn test_view_box_only_svg_falls_back() -> Result<(), Box<dyn Error>> {
use crate::util::dimensions::DimensionsExtractor;
let tmp = std::env::temp_dir().join("fselect_test_viewbox_only.svg");
std::fs::write(
&tmp,
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50"><rect/></svg>"#,
)?;
let result = SvgDimensionsExtractor.try_read_dimensions(&tmp);
let _ = std::fs::remove_file(&tmp);
assert_eq!(
result?,
Some(Dimensions {
width: 100,
height: 50
})
);
Ok(())
}
#[test]
fn test_corrupted() -> Result<(), Box<dyn Error>> {
test_fail(
SvgDimensionsExtractor,
"image/rust-logo-blk_corrupted.svg",
io::ErrorKind::InvalidData,
)
}
}
+55
View File
@@ -0,0 +1,55 @@
use std::fs::File;
use std::io;
use std::path::Path;
use matroska::MatroskaError;
use crate::util::duration::DurationExtractor;
use crate::util::Duration;
pub struct MkvDurationExtractor;
impl DurationExtractor for MkvDurationExtractor {
fn supports_ext(&self, ext_lowercase: &str) -> bool {
"mkv" == ext_lowercase || "webm" == ext_lowercase
}
fn try_read_duration(&self, path: &Path) -> io::Result<Option<Duration>> {
let fd = File::open(path)?;
let matroska = matroska::Matroska::open(fd).map_err(|err| match err {
MatroskaError::Io(io) => io,
MatroskaError::UTF8(utf8) => io::Error::new(io::ErrorKind::InvalidData, utf8),
e => io::Error::new(io::ErrorKind::InvalidData, e),
})?;
match matroska.info.duration {
Some(duration) => {
Ok(Some(Duration {
length: duration.as_secs() as usize,
}))
}
None => Ok(None),
}
}
}
#[cfg(test)]
mod test {
use super::MkvDurationExtractor;
use crate::util::duration::DurationExtractor;
use crate::util::Duration;
use std::error::Error;
use std::path::PathBuf;
#[test]
fn test_success() -> Result<(), Box<dyn Error>> {
let path_string =
std::env::var("CARGO_MANIFEST_DIR")? + "/resources/test/" + "video/rust-logo-blk.mkv";
let path = PathBuf::from(path_string);
assert_eq!(
MkvDurationExtractor.try_read_duration(&path)?,
Some(Duration { length: 1 }),
);
Ok(())
}
}
+41
View File
@@ -0,0 +1,41 @@
mod mkv;
mod mp4;
use std::io;
use std::path::Path;
use mkv::MkvDurationExtractor;
use mp4::Mp4DurationExtractor;
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct Duration {
pub length: usize,
}
/// Extracts the duration of a video container. Audio formats are handled
/// separately by [`crate::util::audio`] via `lofty`, so the remaining
/// extractors only cover the video containers `lofty` does not read (MP4,
/// Matroska).
pub trait DurationExtractor {
fn supports_ext(&self, ext_lowercase: &str) -> bool;
fn try_read_duration(&self, path: &Path) -> io::Result<Option<Duration>>;
}
const EXTRACTORS: [&dyn DurationExtractor; 2] = [
&Mp4DurationExtractor,
&MkvDurationExtractor,
];
pub fn get_duration<T: AsRef<Path>>(path: T) -> Option<Duration> {
let path_ref = path.as_ref();
let extension = path_ref.extension()?.to_str()?;
EXTRACTORS
.iter()
.find(|extractor| extractor.supports_ext(&extension.to_lowercase()))
.and_then(|extractor| {
extractor
.try_read_duration(path_ref)
.unwrap_or_default()
})
}
+62
View File
@@ -0,0 +1,62 @@
use std::fs::File;
use std::io;
use std::io::BufReader;
use std::path::Path;
use crate::util::duration::DurationExtractor;
use crate::util::Duration;
pub struct Mp4DurationExtractor;
impl DurationExtractor for Mp4DurationExtractor {
fn supports_ext(&self, ext_lowercase: &str) -> bool {
// All ISO-BMFF containers mp4parse reads; util/audio.rs excludes
// these from lofty on the assumption this extractor handles them.
matches!(ext_lowercase, "mp4" | "m4v" | "mov" | "3gp")
}
fn try_read_duration(&self, path: &Path) -> io::Result<Option<Duration>> {
let fd = File::open(path)?;
let mut reader = BufReader::new(fd);
let context = mp4parse::read_mp4(&mut reader)?;
// The track header duration is expressed in movie timescale units
// (often 1000, but 600 for QuickTime-origin files).
let timescale = context
.timescale
.map(|ts| ts.0)
.filter(|&ts| ts > 0)
.unwrap_or(1000);
Ok(context
.tracks
.iter()
.find(|track| track.track_type == mp4parse::TrackType::Video)
.and_then(|track| track.tkhd.as_ref())
// Fragmented/DASH files store all-ones for "unknown" (ISO
// 14496-12); reporting it would show a ~4-million-second video.
.filter(|tkhd| tkhd.duration != u64::MAX && tkhd.duration != u32::MAX as u64)
.map(|tkhd| Duration {
length: (tkhd.duration / timescale) as usize,
}))
}
}
#[cfg(test)]
mod test {
use super::Mp4DurationExtractor;
use crate::util::duration::DurationExtractor;
use crate::util::Duration;
use crate::PathBuf;
use std::error::Error;
#[test]
fn test_success() -> Result<(), Box<dyn Error>> {
let path_string =
std::env::var("CARGO_MANIFEST_DIR")? + "/resources/test/" + "video/rust-logo-blk.mp4";
let path = PathBuf::from(path_string);
assert_eq!(
Mp4DurationExtractor.try_read_duration(&path)?,
Some(Duration { length: 1 }),
);
Ok(())
}
}
+149
View File
@@ -0,0 +1,149 @@
use std::fmt;
use std::io;
use std::path::Path;
use std::sync::Mutex;
use nu_ansi_term::Color::Yellow;
#[derive(Debug, Clone, PartialEq)]
pub enum ErrorLevel {
Normal,
Fatal,
}
#[derive(Debug, Clone)]
pub struct SearchError {
pub source: String,
pub description: String,
pub error_level: ErrorLevel,
/// The output consumer stopped reading (e.g. piping into `head`). Fatal in
/// the sense that the search must stop, but not a failure of the search.
broken_pipe: bool,
}
impl SearchError {
pub fn normal(description: impl Into<String>) -> Self {
SearchError { source: String::new(), description: description.into(), error_level: ErrorLevel::Normal, broken_pipe: false }
}
pub fn fatal(description: impl Into<String>) -> Self {
SearchError { source: String::new(), description: description.into(), error_level: ErrorLevel::Fatal, broken_pipe: false }
}
pub fn broken_pipe() -> Self {
SearchError { source: String::from("output"), description: String::from("broken pipe"), error_level: ErrorLevel::Fatal, broken_pipe: true }
}
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = source.into();
self
}
pub fn is_fatal(&self) -> bool {
self.error_level == ErrorLevel::Fatal
}
pub fn is_broken_pipe(&self) -> bool {
self.broken_pipe
}
pub fn print(&self) {
error_message(&self.source, &self.description);
}
}
impl fmt::Display for SearchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.source.is_empty() {
write!(f, "{}", self.description)
} else {
write!(f, "{}: {}", self.source, self.description)
}
}
}
impl From<String> for SearchError {
fn from(s: String) -> Self {
SearchError::normal(s)
}
}
impl From<io::Error> for SearchError {
fn from(e: io::Error) -> Self {
if e.kind() == io::ErrorKind::BrokenPipe {
let mut err = SearchError::fatal(e.to_string());
err.broken_pipe = true;
err
} else {
SearchError::normal(e.to_string())
}
}
}
static NO_ERRORS: Mutex<bool> = Mutex::new(false);
static USE_COLORS: Mutex<bool> = Mutex::new(false);
pub fn get_no_errors() -> bool {
*NO_ERRORS.lock().unwrap()
}
pub fn set_no_errors(value: bool) {
let mut no_errors = NO_ERRORS.lock().unwrap();
*no_errors = value;
}
pub fn set_use_colors(value: bool) {
let mut use_colors = USE_COLORS.lock().unwrap();
*use_colors = value;
}
pub fn path_error_message(p: &Path, e: io::Error) {
error_message(&p.to_string_lossy(), &e.to_string());
}
pub fn error_message(source: &str, description: &str) {
let guard = NO_ERRORS.lock().unwrap();
let no_errors = *guard;
drop(guard);
if !no_errors {
let guard = USE_COLORS.lock().unwrap();
let use_colors = *guard;
drop(guard);
if use_colors {
eprintln!("{}: {}", Yellow.paint(source), description);
} else {
eprintln!("{}: {}", source, description);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn broken_pipe_constructor_is_fatal_and_detectable() {
let err = SearchError::broken_pipe();
assert!(err.is_fatal());
assert!(err.is_broken_pipe());
}
#[test]
fn broken_pipe_io_error_is_detectable() {
let err: SearchError = io::Error::new(io::ErrorKind::BrokenPipe, "pipe closed").into();
assert!(err.is_fatal());
assert!(err.is_broken_pipe());
}
#[test]
fn other_errors_are_not_broken_pipe() {
let err: SearchError = io::Error::new(io::ErrorKind::NotFound, "nope").into();
assert!(!err.is_fatal());
assert!(!err.is_broken_pipe());
assert!(!SearchError::fatal("boom").is_broken_pipe());
assert!(!SearchError::normal("meh").is_broken_pipe());
}
}
+117
View File
@@ -0,0 +1,117 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::HMODULE;
use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
const EVERYTHING_REQUEST_FULL_PATH_AND_FILE_NAME: u32 = 0x0000_0004;
#[derive(Debug)]
pub enum EverythingError {
Unavailable,
Query(u32),
}
type FnSetSearchW = unsafe extern "system" fn(*const u16);
type FnSetRequestFlags = unsafe extern "system" fn(u32);
type FnSetMatchPath = unsafe extern "system" fn(i32);
type FnQueryW = unsafe extern "system" fn(i32) -> i32;
type FnGetNumResults = unsafe extern "system" fn() -> u32;
type FnGetResultFullPathNameW = unsafe extern "system" fn(u32, *mut u16, u32) -> u32;
type FnGetLastError = unsafe extern "system" fn() -> u32;
type FnAction = unsafe extern "system" fn();
struct Api {
set_search: FnSetSearchW,
set_request_flags: FnSetRequestFlags,
set_match_path: FnSetMatchPath,
query: FnQueryW,
get_num_results: FnGetNumResults,
get_full_path: FnGetResultFullPathNameW,
get_last_error: FnGetLastError,
reset: FnAction,
cleanup: FnAction,
}
unsafe impl Send for Api {}
unsafe impl Sync for Api {}
static API: OnceLock<Option<Api>> = OnceLock::new();
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
fn load_api() -> Option<Api> {
unsafe {
let primary = if cfg!(target_pointer_width = "64") {
"Everything64.dll"
} else {
"Everything32.dll"
};
let mut handle: HMODULE = LoadLibraryW(wide(primary).as_ptr());
if handle.is_null() {
handle = LoadLibraryW(wide("Everything.dll").as_ptr());
}
if handle.is_null() {
return None;
}
macro_rules! proc {
($name:literal, $ty:ty) => {{
let p = GetProcAddress(handle, $name.as_ptr())?;
std::mem::transmute::<unsafe extern "system" fn() -> isize, $ty>(p)
}};
}
Some(Api {
set_search: proc!(b"Everything_SetSearchW\0", FnSetSearchW),
set_request_flags: proc!(b"Everything_SetRequestFlags\0", FnSetRequestFlags),
set_match_path: proc!(b"Everything_SetMatchPath\0", FnSetMatchPath),
query: proc!(b"Everything_QueryW\0", FnQueryW),
get_num_results: proc!(b"Everything_GetNumResults\0", FnGetNumResults),
get_full_path: proc!(b"Everything_GetResultFullPathNameW\0", FnGetResultFullPathNameW),
get_last_error: proc!(b"Everything_GetLastError\0", FnGetLastError),
reset: proc!(b"Everything_Reset\0", FnAction),
cleanup: proc!(b"Everything_CleanUp\0", FnAction),
})
}
}
pub fn query_descendants(root_abspath: &str) -> Result<Vec<PathBuf>, EverythingError> {
let api = API
.get_or_init(load_api)
.as_ref()
.ok_or(EverythingError::Unavailable)?;
let mut prefix = root_abspath.to_string();
if !prefix.ends_with('\\') {
prefix.push('\\');
}
let search = wide(&format!("\"{}\"", prefix));
unsafe {
(api.reset)();
(api.set_match_path)(1);
(api.set_request_flags)(EVERYTHING_REQUEST_FULL_PATH_AND_FILE_NAME);
(api.set_search)(search.as_ptr());
if (api.query)(1) == 0 {
return Err(EverythingError::Query((api.get_last_error)()));
}
let count = (api.get_num_results)();
let mut results = Vec::with_capacity(count as usize);
let mut buf = vec![0u16; 32768];
for i in 0..count {
let len = (api.get_full_path)(i, buf.as_mut_ptr(), buf.len() as u32);
if len > 0 {
results.push(PathBuf::from(String::from_utf16_lossy(&buf[..len as usize])));
}
}
(api.cleanup)();
Ok(results)
}
}
+129
View File
@@ -0,0 +1,129 @@
use std::fs::File;
use std::os::unix::io::AsRawFd;
/// FS_IOC_GETFLAGS ioctl request number.
/// Computed as _IOR('f', 1, c_long) = (2 << 30) | (sizeof(c_long) << 16) | ('f' << 8) | 1
const fn fs_ioc_getflags() -> libc::c_ulong {
let dir: libc::c_ulong = 2; // _IOC_READ
let ty: libc::c_ulong = b'f' as libc::c_ulong;
let nr: libc::c_ulong = 1;
let size: libc::c_ulong = std::mem::size_of::<libc::c_long>() as libc::c_ulong;
(dir << 30) | (size << 16) | (ty << 8) | nr
}
const FS_SECRM_FL: libc::c_long = 0x00000001;
const FS_UNRM_FL: libc::c_long = 0x00000002;
const FS_COMPR_FL: libc::c_long = 0x00000004;
const FS_SYNC_FL: libc::c_long = 0x00000008;
const FS_IMMUTABLE_FL: libc::c_long = 0x00000010;
const FS_APPEND_FL: libc::c_long = 0x00000020;
const FS_NODUMP_FL: libc::c_long = 0x00000040;
const FS_NOATIME_FL: libc::c_long = 0x00000080;
const FS_ENCRYPT_FL: libc::c_long = 0x00000800;
const FS_INDEX_FL: libc::c_long = 0x00001000;
const FS_JOURNAL_DATA_FL: libc::c_long = 0x00004000;
const FS_NOTAIL_FL: libc::c_long = 0x00008000;
const FS_DIRSYNC_FL: libc::c_long = 0x00010000;
const FS_TOPDIR_FL: libc::c_long = 0x00020000;
const FS_EXTENT_FL: libc::c_long = 0x00080000;
const FS_VERITY_FL: libc::c_long = 0x00100000;
const FS_NOCOW_FL: libc::c_long = 0x00800000;
const FS_DAX_FL: libc::c_long = 0x02000000;
const FS_INLINE_DATA_FL: libc::c_long = 0x10000000;
const FS_PROJINHERIT_FL: libc::c_long = 0x20000000;
const FS_CASEFOLD_FL: libc::c_long = 0x40000000;
const FLAG_LETTERS: &[(libc::c_long, char)] = &[
(FS_SECRM_FL, 's'),
(FS_UNRM_FL, 'u'),
(FS_COMPR_FL, 'c'),
(FS_SYNC_FL, 'S'),
(FS_IMMUTABLE_FL, 'i'),
(FS_APPEND_FL, 'a'),
(FS_NODUMP_FL, 'd'),
(FS_NOATIME_FL, 'A'),
(FS_ENCRYPT_FL, 'E'),
(FS_INDEX_FL, 'I'),
(FS_JOURNAL_DATA_FL, 'j'),
(FS_NOTAIL_FL, 't'),
(FS_DIRSYNC_FL, 'D'),
(FS_TOPDIR_FL, 'T'),
(FS_EXTENT_FL, 'e'),
(FS_VERITY_FL, 'V'),
(FS_NOCOW_FL, 'C'),
(FS_DAX_FL, 'x'),
(FS_INLINE_DATA_FL, 'N'),
(FS_PROJINHERIT_FL, 'P'),
(FS_CASEFOLD_FL, 'F'),
];
pub fn get_ext_attrs(file: &File) -> Option<libc::c_long> {
let fd = file.as_raw_fd();
let mut flags: libc::c_long = 0;
let ret = unsafe { libc::ioctl(fd, fs_ioc_getflags().try_into().unwrap(), &mut flags) };
if ret == 0 {
Some(flags)
} else {
None
}
}
pub fn format_ext_attrs(flags: libc::c_long) -> String {
let mut result = String::new();
for &(flag, letter) in FLAG_LETTERS {
if flags & flag != 0 {
result.push(letter);
}
}
result
}
pub fn has_ext_attr(flags: libc::c_long, attr: &str) -> bool {
let attr = attr.trim();
if attr.len() != 1 {
return false;
}
let ch = attr.chars().next().unwrap();
for &(flag, letter) in FLAG_LETTERS {
if letter == ch {
return flags & flag != 0;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_ext_attrs_empty() {
assert_eq!(format_ext_attrs(0), "");
}
#[test]
fn test_format_ext_attrs_immutable() {
assert_eq!(format_ext_attrs(FS_IMMUTABLE_FL), "i");
}
#[test]
fn test_format_ext_attrs_multiple() {
let flags = FS_IMMUTABLE_FL | FS_APPEND_FL | FS_EXTENT_FL;
assert_eq!(format_ext_attrs(flags), "iae");
}
#[test]
fn test_has_ext_attr() {
let flags = FS_IMMUTABLE_FL | FS_EXTENT_FL;
assert!(has_ext_attr(flags, "i"));
assert!(has_ext_attr(flags, "e"));
assert!(!has_ext_attr(flags, "a"));
}
#[test]
fn test_has_ext_attr_invalid() {
assert!(!has_ext_attr(0, ""));
assert!(!has_ext_attr(0, "zz"));
assert!(!has_ext_attr(0, "z"));
}
}
+457
View File
@@ -0,0 +1,457 @@
//! Lazy per-repository cache backing the git-related fields.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use git2::{Commit, ObjectType, Repository, Sort, Status, TreeWalkMode, TreeWalkResult};
/// Information about the last commit that touched a particular file.
#[derive(Clone)]
pub struct CommitInfo {
pub hash: String,
/// Commit time as seconds since the Unix epoch.
pub time: i64,
pub author: String,
}
struct RepoEntry {
repo: Repository,
/// Canonicalized work tree root, used to derive repository-relative paths.
workdir: PathBuf,
/// Memoised branch name; the outer `Option` tracks whether it was computed.
branch: Option<Option<String>>,
history: HistoryScan,
}
/// Incremental "last commit that touched each path" resolver. History is
/// walked from HEAD at most once per repository, no matter how many files are
/// queried: each processed commit records itself as the answer for every path
/// it touched (first writer wins, and the walk is newest-first). A per-file
/// `git log -1 -- path` emulation would instead cost
/// O(files x history x tree depth).
#[derive(Default)]
struct HistoryScan {
/// Commit ids from HEAD in time order; collected lazily on first use
/// (a plain revwalk without loading commits is cheap even for large
/// histories, and avoids a self-referential borrow of `repo`).
oids: Option<Vec<git2::Oid>>,
/// Index of the next unprocessed commit in `oids`.
next: usize,
/// Resolved last-touch info per repository-relative path (and per
/// directory: a commit touching `a/b/c.rs` also touches `a` and `a/b`).
resolved: HashMap<String, Rc<CommitInfo>>,
}
/// Caches opened repositories across files of the same traversal, plus the
/// most recent per-file lookups so that several git fields evaluated for the
/// same entry (e.g. in both WHERE and SELECT) don't repeat the work.
#[derive(Default)]
pub struct GitCache {
/// Maps a directory as seen during traversal to the repository covering it
/// and the directory's canonical path, or `None` when the directory does
/// not belong to any work tree.
dirs: HashMap<PathBuf, Option<(usize, PathBuf)>>,
repos: Vec<RepoEntry>,
last_status: Option<(PathBuf, Option<Status>)>,
last_commit: Option<(PathBuf, Option<CommitInfo>)>,
}
impl GitCache {
pub fn new() -> GitCache {
Default::default()
}
/// Current branch of the repository containing `path` ("HEAD" when
/// detached, the unborn branch name in an empty repository).
pub fn branch(&mut self, path: &Path) -> Option<String> {
let (idx, _) = self.locate(path)?;
if self.repos[idx].branch.is_none() {
self.repos[idx].branch = Some(compute_branch(&self.repos[idx].repo));
}
self.repos[idx].branch.clone().unwrap()
}
/// Work tree status flags of the file, `None` when the path is not inside
/// a repository or the status can't be computed (e.g. for directories).
pub fn status(&mut self, path: &Path) -> Option<Status> {
if let Some((cached_path, status)) = &self.last_status
&& cached_path == path {
return *status;
}
let status = self.compute_status(path);
self.last_status = Some((path.to_path_buf(), status));
status
}
pub fn is_tracked(&mut self, path: &Path) -> Option<bool> {
let status = self.status(path)?;
Some(!status.is_wt_new() && !status.is_ignored())
}
pub fn is_ignored(&mut self, path: &Path) -> Option<bool> {
let (idx, rel) = self.locate(path)?;
self.repos[idx].repo.is_path_ignored(to_git_path(&rel)).ok()
}
/// The last commit that touched the file (or directory), like
/// `git log -1 -- path`.
pub fn last_commit(&mut self, path: &Path) -> Option<CommitInfo> {
if let Some((cached_path, commit)) = &self.last_commit
&& cached_path == path {
return commit.clone();
}
let commit = self.locate(path).and_then(|(idx, rel)| {
resolve_last_commit(&mut self.repos[idx], &to_git_path(&rel))
});
self.last_commit = Some((path.to_path_buf(), commit.clone()));
commit
}
fn compute_status(&mut self, path: &Path) -> Option<Status> {
let (idx, rel) = self.locate(path)?;
self.repos[idx].repo.status_file(Path::new(&to_git_path(&rel))).ok()
}
/// Resolves the repository covering `path` and the path relative to its
/// work tree root. Only the parent directory is canonicalized (and cached),
/// so the cost is paid once per directory rather than once per file.
fn locate(&mut self, path: &Path) -> Option<(usize, PathBuf)> {
let parent = path.parent()?;
let file_name = path.file_name()?;
if !self.dirs.contains_key(parent) {
let resolved = fs::canonicalize(parent).ok().and_then(|canonical| {
let repo_idx = self.repo_index_for(&canonical)?;
Some((repo_idx, canonical))
});
self.dirs.insert(parent.to_path_buf(), resolved);
}
let (repo_idx, canonical) = self.dirs.get(parent)?.as_ref()?;
let rel = canonical
.join(file_name)
.strip_prefix(&self.repos[*repo_idx].workdir)
.ok()?
.to_path_buf();
Some((*repo_idx, rel))
}
fn repo_index_for(&mut self, canonical_dir: &Path) -> Option<usize> {
// Discovery has to run per directory (a nested repository or submodule
// takes precedence over an enclosing one), but repositories themselves
// are deduplicated by work tree root.
let repo = Repository::discover(canonical_dir).ok()?;
let workdir = repo.workdir()?.to_path_buf();
let workdir = fs::canonicalize(&workdir).unwrap_or(workdir);
if let Some(idx) = self.repos.iter().position(|r| r.workdir == workdir) {
return Some(idx);
}
self.repos.push(RepoEntry {
repo,
workdir,
branch: None,
history: HistoryScan::default(),
});
Some(self.repos.len() - 1)
}
}
pub fn status_to_string(status: Status) -> &'static str {
if status.is_conflicted() {
"conflicted"
} else if status.intersects(
Status::INDEX_NEW
| Status::INDEX_MODIFIED
| Status::INDEX_DELETED
| Status::INDEX_RENAMED
| Status::INDEX_TYPECHANGE,
) {
"staged"
} else if status.intersects(
Status::WT_MODIFIED | Status::WT_DELETED | Status::WT_RENAMED | Status::WT_TYPECHANGE,
) {
"modified"
} else if status.is_wt_new() {
"untracked"
} else if status.is_ignored() {
"ignored"
} else {
"clean"
}
}
fn compute_branch(repo: &Repository) -> Option<String> {
match repo.head() {
Ok(head) => head.shorthand().ok().map(String::from),
// An unborn branch (fresh repository without commits) still has a
// symbolic HEAD pointing at the future branch name.
Err(_) => repo
.find_reference("HEAD")
.ok()
.and_then(|r| r.symbolic_target().ok().flatten().map(String::from))
.map(|target| {
target
.strip_prefix("refs/heads/")
.map(String::from)
.unwrap_or(target)
}),
}
}
/// Advances the repository's history scan until `key` resolves or the walk is
/// exhausted (then the path was never committed).
fn resolve_last_commit(entry: &mut RepoEntry, key: &str) -> Option<CommitInfo> {
loop {
if let Some(info) = entry.history.resolved.get(key) {
return Some((**info).clone());
}
if !advance_history(entry) {
return None;
}
}
}
/// Processes the next unseen commit of the walk, recording it as the
/// last-touch answer for every path it changed. Returns false once history is
/// exhausted.
fn advance_history(entry: &mut RepoEntry) -> bool {
let repo = &entry.repo;
let oids = entry.history.oids.get_or_insert_with(|| {
let mut oids = Vec::new();
if let Ok(mut revwalk) = repo.revwalk()
&& revwalk.push_head().is_ok()
{
// Topological + time matches `git log` ordering; time alone
// tie-breaks same-second commits arbitrarily, which would pick
// the wrong "last" commit within such a chain.
let _ = revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME);
oids = revwalk.flatten().collect();
}
oids
});
let Some(&oid) = oids.get(entry.history.next) else {
return false;
};
entry.history.next += 1;
let Ok(commit) = repo.find_commit(oid) else {
return true;
};
let touched = commit_touched_paths(repo, &commit);
if touched.is_empty() {
return true;
}
let author = commit.author();
let author_name = author
.name()
.ok()
.or_else(|| author.email().ok())
.map(String::from)
.unwrap_or_default();
let info = Rc::new(CommitInfo {
hash: commit.id().to_string(),
// Author time, to match what `git log` displays by default.
time: author.when().seconds(),
author: author_name,
});
for path in touched {
entry
.history
.resolved
.entry(path)
.or_insert_with(|| info.clone());
}
true
}
/// The paths whose content differs between the commit and *all* of its parents
/// (matching `git log`'s default no-simplification behavior for a single
/// pathspec), plus every ancestor directory of each, so that directory
/// queries resolve to the newest commit touching anything beneath them. The
/// root commit touches everything in its tree.
fn commit_touched_paths(repo: &Repository, commit: &Commit) -> Vec<String> {
let Ok(tree) = commit.tree() else {
return Vec::new();
};
if commit.parent_count() == 0 {
let mut paths = Vec::new();
let _ = tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
if (entry.kind() == Some(ObjectType::Blob) || entry.kind() == Some(ObjectType::Tree))
&& let Ok(name) = entry.name()
{
paths.push(format!("{}{}", dir, name));
}
TreeWalkResult::Ok
});
return paths;
}
let mut intersection: Option<HashSet<String>> = None;
for parent in commit.parents() {
let Ok(parent_tree) = parent.tree() else {
continue;
};
let Ok(diff) = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None) else {
continue;
};
let mut changed = HashSet::new();
for delta in diff.deltas() {
for path in [delta.old_file().path(), delta.new_file().path()]
.into_iter()
.flatten()
{
add_with_ancestors(&mut changed, &path.to_string_lossy());
}
}
intersection = Some(match intersection {
None => changed,
Some(previous) => previous.intersection(&changed).cloned().collect(),
});
}
intersection.map(Vec::from_iter).unwrap_or_default()
}
/// Inserts a '/'-separated path and each of its parent directories.
fn add_with_ancestors(set: &mut HashSet<String>, path: &str) {
let mut end = path.len();
loop {
set.insert(path[..end].to_string());
match path[..end].rfind('/') {
Some(idx) if idx > 0 => end = idx,
_ => break,
}
}
}
/// git2 expects repository-relative paths with forward slashes.
fn to_git_path(rel: &Path) -> String {
let s = rel.to_string_lossy();
if cfg!(windows) {
s.replace('\\', "/")
} else {
s.into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use git2::{IndexAddOption, Signature, Time};
/// Stages everything and commits with a distinct timestamp — the walk is
/// time-sorted, so equal-second commits would tie-break arbitrarily.
fn commit_all(repo: &Repository, msg: &str, secs: i64) -> git2::Oid {
let sig = Signature::new("test", "test@test", &Time::new(secs, 0)).unwrap();
let mut index = repo.index().unwrap();
index
.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
.unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&git2::Commit> = parent.iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
.unwrap()
}
#[test]
fn last_commit_resolves_per_path() {
let dir = std::env::temp_dir().join("fselect_test_git_last_commit");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("sub")).unwrap();
let repo = Repository::init(&dir).unwrap();
fs::write(dir.join("a.txt"), "one").unwrap();
let c1 = commit_all(&repo, "c1", 1_000_000_000);
fs::write(dir.join("b.txt"), "two").unwrap();
let c2 = commit_all(&repo, "c2", 1_000_000_100);
fs::write(dir.join("sub").join("c.txt"), "three").unwrap();
fs::write(dir.join("a.txt"), "one-modified").unwrap();
let c3 = commit_all(&repo, "c3", 1_000_000_200);
fs::write(dir.join("untracked.txt"), "x").unwrap();
let mut cache = GitCache::new();
assert_eq!(
cache.last_commit(&dir.join("a.txt")).unwrap().hash,
c3.to_string(),
"modified file resolves to the modifying commit"
);
assert_eq!(
cache.last_commit(&dir.join("b.txt")).unwrap().hash,
c2.to_string(),
"untouched-since file resolves to its introducing commit"
);
assert_eq!(
cache.last_commit(&dir.join("sub").join("c.txt")).unwrap().hash,
c3.to_string()
);
assert_eq!(
cache.last_commit(&dir.join("sub")).unwrap().hash,
c3.to_string(),
"directory resolves to the newest commit under it"
);
assert!(
cache.last_commit(&dir.join("untracked.txt")).is_none(),
"never-committed file has no last commit"
);
// Second query for the same repository must reuse the finished walk.
assert_eq!(
cache.last_commit(&dir.join("a.txt")).unwrap().hash,
c3.to_string()
);
let _ = c1;
drop(repo);
drop(cache);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn last_commit_root_commit_touches_all_paths() {
let dir = std::env::temp_dir().join("fselect_test_git_root_commit");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("nested")).unwrap();
let repo = Repository::init(&dir).unwrap();
fs::write(dir.join("top.txt"), "t").unwrap();
fs::write(dir.join("nested").join("deep.txt"), "d").unwrap();
let c1 = commit_all(&repo, "root", 1_000_000_000);
let mut cache = GitCache::new();
assert_eq!(
cache.last_commit(&dir.join("top.txt")).unwrap().hash,
c1.to_string()
);
assert_eq!(
cache
.last_commit(&dir.join("nested").join("deep.txt"))
.unwrap()
.hash,
c1.to_string()
);
assert_eq!(
cache.last_commit(&dir.join("nested")).unwrap().hash,
c1.to_string()
);
drop(repo);
drop(cache);
let _ = fs::remove_dir_all(&dir);
}
}
+254
View File
@@ -0,0 +1,254 @@
use std::ops::Index;
use std::sync::LazyLock;
use regex::Captures;
use regex::Regex;
static GLOB_SPECIAL_CHARS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new("(\\?|\\.|\\*|\\[|\\]|\\(|\\)|\\^|\\$|\\+|\\{|\\}|\\||\\\\)").unwrap()
});
pub fn is_glob(s: &str) -> bool {
s.contains("*") || s.contains('?')
}
pub fn convert_glob_to_pattern(s: &str) -> Result<String, String> {
let string = GLOB_SPECIAL_CHARS.replace_all(s, |c: &Captures| {
match c.index(0) {
"." => "\\.",
"*" => ".*",
"?" => ".",
"[" => "\\[",
"]" => "\\]",
"(" => "\\(",
")" => "\\)",
"^" => "\\^",
"$" => "\\$",
"+" => "\\+",
"{" => "\\{",
"}" => "\\}",
"|" => "\\|",
"\\" => "\\\\",
_ => "",
}
.to_string()
});
if string.is_empty() {
return Err("Error parsing glob expression: ".to_string() + s);
}
Ok(format!("^(?i){}$", string))
}
pub fn convert_like_to_pattern(s: &str) -> Result<String, String> {
fn push_literal(c: char, out: &mut String) {
match c {
'?' | '.' | '*' | '[' | ']' | '(' | ')' | '^' | '$' | '+' | '{' | '}' | '|' | '\\' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
let mut string = String::with_capacity(s.len() + 8);
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
// MySQL default escape character: \_ , \% and \\ match the literal
// character; a backslash before anything else stays a literal backslash
'\\' => match chars.peek() {
Some(&next @ ('_' | '%' | '\\')) => {
chars.next();
push_literal(next, &mut string);
}
_ => string.push_str("\\\\"),
},
'%' => string.push_str(".*"),
'_' => string.push('.'),
_ => push_literal(c, &mut string),
}
}
if string.is_empty() {
return Err("Error parsing LIKE expression: ".to_string() + s);
}
Ok(format!("^(?i){}$", string))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_glob_with_asterisk() {
assert!(is_glob("file*.txt"));
assert!(is_glob("*file.txt"));
assert!(is_glob("file.txt*"));
}
#[test]
fn test_is_glob_with_question_mark() {
assert!(is_glob("file?.txt"));
assert!(is_glob("?file.txt"));
assert!(is_glob("file.txt?"));
}
#[test]
fn test_is_glob_with_no_glob_chars() {
assert!(!is_glob("file.txt"));
assert!(!is_glob("path/to/file.txt"));
assert!(!is_glob(""));
}
#[test]
fn test_convert_glob_to_pattern_asterisk() {
let pattern = convert_glob_to_pattern("*.txt").unwrap();
assert_eq!(pattern, "^(?i).*\\.txt$");
}
#[test]
fn test_convert_glob_to_pattern_question_mark() {
let pattern = convert_glob_to_pattern("file?.txt").unwrap();
assert_eq!(pattern, "^(?i)file.\\.txt$");
}
#[test]
fn test_convert_glob_to_pattern_mixed() {
let pattern = convert_glob_to_pattern("file-*.?xt").unwrap();
assert_eq!(pattern, "^(?i)file-.*\\..xt$");
}
#[test]
fn test_convert_glob_to_pattern_special_chars() {
let pattern = convert_glob_to_pattern("file[1-3].txt").unwrap();
assert_eq!(pattern, "^(?i)file\\[1-3\\]\\.txt$");
}
#[test]
fn test_convert_like_to_pattern_percent() {
let pattern = convert_like_to_pattern("%.txt").unwrap();
assert_eq!(pattern, "^(?i).*\\.txt$");
}
#[test]
fn test_convert_like_to_pattern_underscore() {
let pattern = convert_like_to_pattern("file_.txt").unwrap();
assert_eq!(pattern, "^(?i)file.\\.txt$");
}
#[test]
fn test_convert_like_to_pattern_mixed() {
let pattern = convert_like_to_pattern("file-%.txt").unwrap();
assert_eq!(pattern, "^(?i)file-.*\\.txt$");
}
#[test]
fn test_convert_like_to_pattern_question_mark() {
let pattern = convert_like_to_pattern("file?.txt").unwrap();
assert_eq!(pattern, "^(?i)file\\?\\.txt$");
}
#[test]
fn test_convert_like_question_mark_is_literal() {
// In SQL LIKE, '?' has no special meaning and should be treated as literal
let pattern = convert_like_to_pattern("file?.txt").unwrap();
let re = regex::Regex::new(&pattern).unwrap();
assert!(re.is_match("file?.txt"));
assert!(!re.is_match("filex.txt"));
assert!(!re.is_match("file.txt"));
}
#[test]
fn test_convert_like_to_pattern_special_chars() {
let pattern = convert_like_to_pattern("file*.txt").unwrap();
assert_eq!(pattern, "^(?i)file\\*\\.txt$");
}
#[test]
fn test_convert_glob_escapes_plus() {
let pattern = convert_glob_to_pattern("a+b.txt").unwrap();
assert_eq!(pattern, "^(?i)a\\+b\\.txt$");
}
#[test]
fn test_convert_glob_escapes_braces() {
let pattern = convert_glob_to_pattern("file{1}.txt").unwrap();
assert_eq!(pattern, "^(?i)file\\{1\\}\\.txt$");
}
#[test]
fn test_convert_glob_escapes_pipe() {
let pattern = convert_glob_to_pattern("a|b.txt").unwrap();
assert_eq!(pattern, "^(?i)a\\|b\\.txt$");
}
#[test]
fn test_convert_like_escapes_plus() {
let pattern = convert_like_to_pattern("a+b").unwrap();
assert_eq!(pattern, "^(?i)a\\+b$");
}
#[test]
fn test_convert_like_escapes_braces() {
let pattern = convert_like_to_pattern("file{1}").unwrap();
assert_eq!(pattern, "^(?i)file\\{1\\}$");
}
#[test]
fn test_convert_like_escaped_underscore() {
let pattern = convert_like_to_pattern("a\\_b.txt").unwrap();
assert_eq!(pattern, "^(?i)a_b\\.txt$");
let re = regex::Regex::new(&pattern).unwrap();
assert!(re.is_match("a_b.txt"));
assert!(!re.is_match("axb.txt"));
}
#[test]
fn test_convert_like_escaped_percent() {
let pattern = convert_like_to_pattern("100\\%").unwrap();
assert_eq!(pattern, "^(?i)100%$");
let re = regex::Regex::new(&pattern).unwrap();
assert!(re.is_match("100%"));
assert!(!re.is_match("100200"));
}
#[test]
fn test_convert_like_escaped_backslash() {
let pattern = convert_like_to_pattern("a\\\\b").unwrap();
assert_eq!(pattern, "^(?i)a\\\\b$");
let re = regex::Regex::new(&pattern).unwrap();
assert!(re.is_match("a\\b"));
assert!(!re.is_match("ab"));
}
#[test]
fn test_convert_like_backslash_before_other_char_is_literal() {
// MySQL keeps the backslash when it does not precede a wildcard or backslash
let pattern = convert_like_to_pattern("a\\b").unwrap();
assert_eq!(pattern, "^(?i)a\\\\b$");
let re = regex::Regex::new(&pattern).unwrap();
assert!(re.is_match("a\\b"));
assert!(!re.is_match("ab"));
}
#[test]
fn test_convert_like_trailing_backslash_is_literal() {
let pattern = convert_like_to_pattern("dir\\").unwrap();
assert_eq!(pattern, "^(?i)dir\\\\$");
let re = regex::Regex::new(&pattern).unwrap();
assert!(re.is_match("dir\\"));
assert!(!re.is_match("dir"));
}
#[test]
fn test_convert_like_unescaped_wildcards_still_work() {
let pattern = convert_like_to_pattern("a\\_b%").unwrap();
let re = regex::Regex::new(&pattern).unwrap();
assert!(re.is_match("a_b"));
assert!(re.is_match("a_b-anything"));
assert!(!re.is_match("aXb"));
}
}
+9
View File
@@ -0,0 +1,9 @@
pub fn contains_greek(s: &str) -> bool {
s.chars().any(is_greek_char)
}
fn is_greek_char(c: char) -> bool {
// Greek and Coptic: U+0370U+03FF
// Greek Extended: U+1F00U+1FFF
matches!(c, '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}')
}
+19
View File
@@ -0,0 +1,19 @@
pub fn contains_japanese(s: &str) -> bool {
s.chars().any(wana_kana::utils::is_char_japanese)
}
pub fn contains_hiragana(s: &str) -> bool {
s.chars().any(wana_kana::utils::is_char_hiragana)
}
pub fn contains_katakana(s: &str) -> bool {
s.chars().any(wana_kana::utils::is_char_katakana)
}
pub fn contains_kana(s: &str) -> bool {
s.chars().any(wana_kana::utils::is_char_kana)
}
pub fn contains_kanji(s: &str) -> bool {
s.chars().any(wana_kana::utils::is_char_kanji)
}
+2078
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug)]
pub enum PlocateError {
Spawn,
Failed(Option<i32>),
}
pub fn query_descendants(root_abspath: &str) -> Result<Vec<PathBuf>, PlocateError> {
let mut prefix = root_abspath.to_string();
if !prefix.ends_with('/') {
prefix.push('/');
}
let output = Command::new("plocate")
.arg("--null")
.arg(&prefix)
.output()
.map_err(|_| PlocateError::Spawn)?;
if !output.status.success() {
match output.status.code() {
Some(0) | Some(1) => {}
other => return Err(PlocateError::Failed(other)),
}
}
use std::os::unix::ffi::OsStrExt;
let mut results = Vec::new();
for chunk in output.stdout.split(|&b| b == 0) {
if !chunk.is_empty() {
results.push(PathBuf::from(std::ffi::OsStr::from_bytes(chunk)));
}
}
Ok(results)
}
+134
View File
@@ -0,0 +1,134 @@
use std::collections::BTreeMap;
#[derive(Debug)]
pub struct TopN<K: Ord, V> {
limit: Option<u32>,
count: u32,
echelons: BTreeMap<K, Vec<V>>,
}
impl<K: Ord, V> TopN<K, V> {
pub fn new(limit: u32) -> TopN<K, V> {
debug_assert_ne!(limit, 0);
TopN {
limit: Some(limit),
count: 0,
echelons: BTreeMap::new(),
}
}
pub fn limitless() -> TopN<K, V> {
TopN {
limit: None,
count: 0,
echelons: BTreeMap::new(),
}
}
pub fn insert(&mut self, k: K, v: V) -> Option<V>
where
K: Clone,
{
self.count += 1;
self.echelons.entry(k).or_default().push(v);
if let Some(limit) = self.limit
&& limit < self.count {
self.count -= 1;
let (last_key, mut last_echelon) = self.echelons.pop_last().unwrap();
let popped = last_echelon.pop().unwrap();
if !last_echelon.is_empty() {
self.echelons.insert(last_key, last_echelon);
}
return Some(popped);
}
None
}
pub fn iter_values(&self) -> impl Iterator<Item = &V> {
self.echelons.values().flatten()
}
#[cfg(test)]
pub fn values(&self) -> Vec<V>
where
V: Clone,
{
self.iter_values().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_one() {
let mut top_n = TopN::new(5);
top_n.insert("asdf", 1);
}
#[test]
fn test_insert_to_limit() {
let mut top_n = TopN::new(2);
top_n.insert("asdf", 1);
top_n.insert("xyz", 2);
}
#[test]
fn test_insert_past_limit_bigger_discarded() {
let mut top_n = TopN::new(2);
top_n.insert("a", 1);
top_n.insert("b", 2);
top_n.insert("z", -1);
assert_eq!(top_n.values(), vec![1, 2]);
}
#[test]
fn test_insert_past_limit_equal_discarded() {
let mut top_n = TopN::new(2);
top_n.insert("a", 1);
top_n.insert("b", 2);
top_n.insert("b", -1);
assert_eq!(top_n.values(), vec![1, 2]);
}
#[test]
fn test_insert_past_limit_smaller_last_one_discarded() {
let mut top_n = TopN::new(2);
top_n.insert("b", "second");
top_n.insert("c", "last");
top_n.insert("a", "first");
assert_eq!(top_n.values(), vec!["first", "second"]);
}
#[test]
fn test_insert_past_limit_comprehensive() {
let mut top_n = TopN::new(5);
top_n.insert("asdf", 1);
assert_eq!(top_n.values(), vec![1]);
top_n.insert("asdf", 3);
assert_eq!(top_n.values(), vec![1, 3]);
top_n.insert("asdf", 3);
assert_eq!(top_n.values(), vec![1, 3, 3]);
top_n.insert("xyz", 4);
assert_eq!(top_n.values(), vec![1, 3, 3, 4]);
top_n.insert("asdf", 2);
assert_eq!(top_n.values(), vec![1, 3, 3, 2, 4]);
top_n.insert("xyz", 5);
assert_eq!(top_n.values(), vec![1, 3, 3, 2, 4]);
top_n.insert("asdf", -1);
assert_eq!(top_n.values(), vec![1, 3, 3, 2, -1]);
}
#[test]
fn test_limitless() {
let mut top_n = TopN::limitless();
top_n.insert("z", 3);
top_n.insert("y", 2);
top_n.insert("a", 1);
top_n.insert("a", 0);
assert_eq!(top_n.values(), vec![1, 0, 2, 3]);
}
}
+529
View File
@@ -0,0 +1,529 @@
use std::fmt::{Display, Error, Formatter};
use chrono::NaiveDateTime;
use crate::util::{format_datetime, parse_datetime, parse_filesize, str_to_bool};
#[derive(Clone, Debug)]
pub enum VariantType {
String,
Int,
Float,
Bool,
DateTime,
}
#[derive(Clone, Debug)]
pub struct Variant {
value_type: VariantType,
string_value: String,
int_value: Option<i64>,
float_value: Option<f64>,
bool_value: Option<bool>,
dt_from: Option<NaiveDateTime>,
dt_to: Option<NaiveDateTime>,
}
impl Variant {
pub fn empty(value_type: VariantType) -> Variant {
Variant {
value_type,
string_value: String::new(),
int_value: None,
float_value: None,
bool_value: None,
dt_from: None,
dt_to: None,
}
}
pub fn get_type(&self) -> &VariantType {
&self.value_type
}
pub fn from_int(value: i64) -> Variant {
Variant {
value_type: VariantType::Int,
string_value: format!("{}", value),
int_value: Some(value),
float_value: Some(value as f64),
bool_value: None,
dt_from: None,
dt_to: None,
}
}
pub fn from_float(value: f64) -> Variant {
if value.is_nan() || value.is_infinite() {
return Variant::empty(VariantType::Float);
}
let value = value + 0.0;
Variant {
value_type: VariantType::Float,
string_value: format!("{}", value),
int_value: Some(value as i64),
float_value: Some(value),
bool_value: None,
dt_from: None,
dt_to: None,
}
}
pub fn from_string(value: &String) -> Variant {
Variant {
value_type: VariantType::String,
string_value: value.to_owned(),
int_value: None,
float_value: None,
bool_value: None,
dt_from: None,
dt_to: None,
}
}
pub fn from_signed_string(value: &String, minus: bool) -> Variant {
let string_value = match minus {
true => {
let mut result = String::from("-");
result += &value.to_owned();
result
}
false => value.to_owned(),
};
Variant {
value_type: VariantType::String,
string_value,
int_value: None,
float_value: None,
bool_value: None,
dt_from: None,
dt_to: None,
}
}
pub fn from_bool(value: bool) -> Variant {
Variant {
value_type: VariantType::Bool,
string_value: match value {
true => String::from("true"),
_ => String::from("false"),
},
int_value: match value {
true => Some(1),
_ => Some(0),
},
float_value: None,
bool_value: Some(value),
dt_from: None,
dt_to: None,
}
}
pub fn from_datetime(value: NaiveDateTime) -> Variant {
Variant {
value_type: VariantType::DateTime,
string_value: format_datetime(&value),
int_value: Some(0),
float_value: None,
bool_value: None,
dt_from: Some(value),
dt_to: Some(value),
}
}
pub fn to_int(&self) -> i64 {
match self.int_value {
Some(i) => i,
None => {
if let Some(f) = self.float_value {
return f as i64;
}
let int_value = self.string_value.parse::<i64>();
match int_value {
Ok(i) => i,
_ => match self.string_value.parse::<f64>() {
Ok(f) if f.is_finite() => f as i64,
_ => match parse_filesize(&self.string_value) {
Some(size) => size as i64,
_ => 0,
},
},
}
}
}
}
pub fn to_float(&self) -> f64 {
if let Some(f) = self.float_value {
return f;
}
match self.int_value {
Some(i) => i as f64,
None => {
let float_value = self.string_value.parse::<f64>();
match float_value {
Ok(f) if f.is_finite() => f,
_ => match parse_filesize(&self.string_value) {
Some(size) => size as f64,
_ => 0.0,
},
}
}
}
}
pub fn to_bool(&self) -> bool {
if let Some(value) = self.bool_value {
return value;
}
if !self.string_value.is_empty()
&& let Some(value) = str_to_bool(&self.string_value) {
return value;
}
if let Some(int_value) = self.int_value {
int_value != 0
} else if let Some(float_value) = self.float_value {
float_value != 0.0
} else {
false
}
}
pub fn to_datetime(&self) -> Result<(NaiveDateTime, NaiveDateTime), String> {
if let Some(dt_from) = self.dt_from {
Ok((dt_from, self.dt_to.unwrap()))
} else {
match parse_datetime(&self.string_value) {
Ok((dt_from, dt_to)) => {
Ok((dt_from, dt_to))
}
_ => Err(String::from("Can't parse datetime: ") + &self.string_value),
}
}
}
}
impl Display for Variant {
fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
write!(f, "{}", self.string_value)
}
}
#[cfg(test)]
// 3.14 is used throughout as an arbitrary float test value, not as an
// approximation of PI, so the approx_constant lint does not apply here.
#[allow(clippy::approx_constant)]
mod tests {
use super::*;
use chrono::{NaiveDate, Timelike};
#[test]
fn from_int() {
let v = Variant::from_int(42);
assert_eq!(v.to_string(), "42");
assert_eq!(v.to_int(), 42);
assert_eq!(v.to_float(), 42.0);
assert!(matches!(v.get_type(), VariantType::Int));
}
#[test]
fn from_int_negative() {
let v = Variant::from_int(-7);
assert_eq!(v.to_string(), "-7");
assert_eq!(v.to_int(), -7);
assert_eq!(v.to_float(), -7.0);
}
#[test]
fn from_int_zero() {
let v = Variant::from_int(0);
assert_eq!(v.to_int(), 0);
assert_eq!(v.to_float(), 0.0);
assert_eq!(v.to_string(), "0");
}
#[test]
fn from_float() {
let v = Variant::from_float(3.14);
assert_eq!(v.to_float(), 3.14);
assert_eq!(v.to_int(), 3);
assert_eq!(v.to_string(), "3.14");
assert!(matches!(v.get_type(), VariantType::Float));
}
#[test]
fn from_float_negative() {
let v = Variant::from_float(-2.5);
assert_eq!(v.to_float(), -2.5);
assert_eq!(v.to_int(), -2);
}
#[test]
fn from_float_whole_number() {
let v = Variant::from_float(5.0);
assert_eq!(v.to_float(), 5.0);
assert_eq!(v.to_int(), 5);
}
#[test]
fn from_string_plain() {
let v = Variant::from_string(&String::from("hello"));
assert_eq!(v.to_string(), "hello");
assert!(matches!(v.get_type(), VariantType::String));
}
#[test]
fn from_string_numeric() {
let v = Variant::from_string(&String::from("123"));
assert_eq!(v.to_string(), "123");
assert_eq!(v.to_int(), 123);
assert_eq!(v.to_float(), 123.0);
}
#[test]
fn from_string_non_numeric_to_int() {
let v = Variant::from_string(&String::from("abc"));
assert_eq!(v.to_int(), 0);
}
#[test]
fn from_string_non_numeric_to_float() {
let v = Variant::from_string(&String::from("abc"));
assert_eq!(v.to_float(), 0.0);
}
#[test]
fn from_string_filesize_to_int() {
let v = Variant::from_string(&String::from("1k"));
assert_eq!(v.to_int(), 1024);
}
#[test]
fn from_string_filesize_to_float() {
let v = Variant::from_string(&String::from("1k"));
assert_eq!(v.to_float(), 1024.0);
}
#[test]
fn from_string_float_value() {
let v = Variant::from_string(&String::from("3.14"));
assert_eq!(v.to_float(), 3.14);
}
#[test]
fn from_signed_string_positive() {
let v = Variant::from_signed_string(&String::from("42"), false);
assert_eq!(v.to_string(), "42");
assert!(matches!(v.get_type(), VariantType::String));
}
#[test]
fn from_signed_string_negative() {
let v = Variant::from_signed_string(&String::from("42"), true);
assert_eq!(v.to_string(), "-42");
}
#[test]
fn from_bool_true() {
let v = Variant::from_bool(true);
assert!(v.to_bool());
assert_eq!(v.to_string(), "true");
assert_eq!(v.to_int(), 1);
assert!(matches!(v.get_type(), VariantType::Bool));
}
#[test]
fn from_bool_false() {
let v = Variant::from_bool(false);
assert!(!v.to_bool());
assert_eq!(v.to_string(), "false");
assert_eq!(v.to_int(), 0);
}
#[test]
fn from_datetime() {
let dt = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap().and_hms_opt(10, 30, 0).unwrap();
let v = Variant::from_datetime(dt);
assert_eq!(v.to_string(), "2024-06-15 10:30:00");
assert!(matches!(v.get_type(), VariantType::DateTime));
let (from, to) = v.to_datetime().unwrap();
assert_eq!(from, dt);
assert_eq!(to, dt);
}
#[test]
fn to_datetime_from_string() {
let v = Variant::from_string(&String::from("2024-06-15"));
let result = v.to_datetime();
assert!(result.is_ok());
}
#[test]
fn to_datetime_date_only_returns_day_range() {
let v = Variant::from_string(&String::from("2024-06-15"));
let (start, finish) = v.to_datetime().unwrap();
// Date-only should span midnight to 23:59:59
assert_eq!(start.hour(), 0);
assert_eq!(finish.hour(), 23);
assert_eq!(finish.minute(), 59);
assert_eq!(finish.second(), 59);
}
#[test]
fn to_datetime_from_invalid_string() {
let v = Variant::from_string(&String::from("not-a-date"));
let result = v.to_datetime();
assert!(result.is_err());
}
#[test]
fn to_bool_from_string_true_variants() {
for val in &["true", "1", "yes", "y", "on"] {
let v = Variant::from_string(&String::from(*val));
assert!(v.to_bool(), "expected true for '{}'", val);
}
}
#[test]
fn to_bool_from_string_false_variants() {
for val in &["false", "0", "no", "n", "off"] {
let v = Variant::from_string(&String::from(*val));
assert!(!v.to_bool(), "expected false for '{}'", val);
}
}
#[test]
fn to_bool_from_string_unknown() {
let v = Variant::from_string(&String::from("maybe"));
assert!(!v.to_bool());
}
#[test]
fn to_bool_from_int_one() {
let v = Variant::from_int(1);
assert!(v.to_bool());
}
#[test]
fn to_bool_from_int_zero() {
let v = Variant::from_int(0);
assert!(!v.to_bool());
}
#[test]
fn to_bool_from_int_other() {
let v = Variant::from_int(42);
assert!(v.to_bool(), "non-zero int should be truthy");
let v = Variant::from_int(-1);
assert!(v.to_bool(), "negative int should be truthy");
}
#[test]
fn to_bool_from_float_one() {
let v = Variant::from_float(1.0);
assert!(v.to_bool());
}
#[test]
fn to_bool_from_float_zero() {
let v = Variant::from_float(0.0);
assert!(!v.to_bool());
}
#[test]
fn to_bool_from_float_nonzero() {
let v = Variant::from_float(3.14);
assert!(v.to_bool(), "non-zero float should be truthy");
}
#[test]
fn empty_variant() {
let v = Variant::empty(VariantType::String);
assert_eq!(v.to_string(), "");
assert!(matches!(v.get_type(), VariantType::String));
}
#[test]
fn empty_variant_to_int() {
let v = Variant::empty(VariantType::Int);
assert_eq!(v.to_int(), 0);
}
#[test]
fn empty_variant_to_bool() {
let v = Variant::empty(VariantType::Bool);
assert!(!v.to_bool());
}
#[test]
fn display_trait() {
let v = Variant::from_int(99);
assert_eq!(format!("{}", v), "99");
}
#[test]
fn display_trait_string() {
let v = Variant::from_string(&String::from("test"));
assert_eq!(format!("{}", v), "test");
}
#[test]
fn from_string_negative_to_int() {
let v = Variant::from_string(&String::from("-42"));
assert_eq!(v.to_int(), -42);
}
#[test]
fn from_string_negative_to_float() {
let v = Variant::from_string(&String::from("-42"));
assert_eq!(v.to_float(), -42.0);
}
#[test]
fn from_string_nan_to_float_not_nan() {
let v = Variant::from_string(&String::from("NaN"));
assert!(!v.to_float().is_nan(), "to_float should not return NaN from string");
}
#[test]
fn from_string_inf_to_float_not_inf() {
let v = Variant::from_string(&String::from("inf"));
assert!(v.to_float().is_finite(), "to_float should not return inf from string");
}
#[test]
fn from_string_neg_inf_to_float_not_inf() {
let v = Variant::from_string(&String::from("-inf"));
assert!(v.to_float().is_finite(), "to_float should not return -inf from string");
}
#[test]
fn from_string_infinity_to_float_not_inf() {
let v = Variant::from_string(&String::from("infinity"));
assert!(v.to_float().is_finite(), "to_float should not return infinity from string");
}
#[test]
fn from_string_float_to_int() {
let v = Variant::from_string(&String::from("3.14"));
assert_eq!(v.to_int(), 3);
}
#[test]
fn from_string_negative_float_to_int() {
let v = Variant::from_string(&String::from("-2.7"));
assert_eq!(v.to_int(), -2);
}
#[test]
fn clone() {
let v1 = Variant::from_int(10);
let v2 = v1.clone();
assert_eq!(v2.to_int(), 10);
assert_eq!(v2.to_string(), "10");
}
}
+122
View File
@@ -0,0 +1,122 @@
use std::io;
use std::io::Write;
#[derive(Debug)]
pub struct WritableBuffer {
buf: String,
// Incomplete UTF-8 tail carried between writes: buffered writers (e.g.
// csv::Writer) flush at arbitrary byte offsets, so a chunk may end in the
// middle of a multibyte sequence that the next chunk completes.
pending: Vec<u8>,
}
impl WritableBuffer {
pub fn new() -> WritableBuffer {
WritableBuffer {
buf: String::new(),
pending: Vec::new(),
}
}
}
impl From<WritableBuffer> for String {
fn from(mut wb: WritableBuffer) -> Self {
if !wb.pending.is_empty() {
// A truncated final sequence: surface it as replacement chars
// rather than silently dropping bytes.
wb.buf.push_str(&String::from_utf8_lossy(&wb.pending));
}
wb.buf
}
}
impl Write for WritableBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut owned: Vec<u8>;
let bytes: &[u8] = if self.pending.is_empty() {
buf
} else {
owned = std::mem::take(&mut self.pending);
owned.extend_from_slice(buf);
&owned
};
match std::str::from_utf8(bytes) {
Ok(s) => {
self.buf.push_str(s);
}
Err(err) => {
if err.error_len().is_some() {
// Genuinely invalid bytes, not a sequence split by
// chunking.
return Err(io::ErrorKind::InvalidInput.into());
}
let valid_up_to = err.valid_up_to();
let valid = std::str::from_utf8(&bytes[..valid_up_to])
.expect("prefix up to valid_up_to is valid UTF-8");
self.buf.push_str(valid);
self.pending = bytes[valid_up_to..].to_vec();
}
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_utf8_and_appends() {
let mut wb = WritableBuffer::new();
assert_eq!(wb.write(b"hello").unwrap(), 5);
assert_eq!(wb.write(b" world").unwrap(), 6);
assert_eq!(String::from(wb), "hello world");
}
#[test]
fn writes_multibyte_utf8() {
let mut wb = WritableBuffer::new();
let s = "héllo 🌍".as_bytes();
assert_eq!(wb.write(s).unwrap(), s.len());
assert_eq!(String::from(wb), "héllo 🌍");
}
#[test]
fn rejects_invalid_utf8() {
let mut wb = WritableBuffer::new();
let err = wb.write(&[0xff, 0xfe, 0xfd]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn accepts_multibyte_sequence_split_across_writes() {
// "é" is [0xC3, 0xA9]; a buffered writer may split it anywhere.
let mut wb = WritableBuffer::new();
assert_eq!(wb.write(&[b'a', 0xC3]).unwrap(), 2);
assert_eq!(wb.write(&[0xA9, b'b']).unwrap(), 2);
assert_eq!(String::from(wb), "aéb");
}
#[test]
fn accepts_four_byte_sequence_split_byte_by_byte() {
let mut wb = WritableBuffer::new();
for &b in "🌍".as_bytes() {
assert_eq!(wb.write(&[b]).unwrap(), 1);
}
assert_eq!(String::from(wb), "🌍");
}
#[test]
fn rejects_invalid_continuation_after_split() {
let mut wb = WritableBuffer::new();
assert_eq!(wb.write(&[0xC3]).unwrap(), 1);
let err = wb.write(&[b'x']).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
}
+467
View File
@@ -0,0 +1,467 @@
//! Windows ACL detection and formatting via the Win32 Security API.
//!
//! Uses `GetNamedSecurityInfoW` to retrieve the DACL, then inspects
//! its ACEs. A file "has ACL" if its DACL contains at least one
//! explicit (non-inherited) Access Control Entry.
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::ptr;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Authorization::{
GetNamedSecurityInfoW, SE_FILE_OBJECT,
};
use windows_sys::Win32::Security::{
ACE_HEADER, ACL as WIN_ACL,
DACL_SECURITY_INFORMATION, INHERITED_ACE,
LookupAccountSidW, SID_NAME_USE,
};
const ACE_TYPE_ACCESS_ALLOWED: u8 = 0;
const ACE_TYPE_ACCESS_DENIED: u8 = 1;
/// ACE inheritance flags. An ACE that carries either of these is propagated to
/// child objects, which is the closest Windows equivalent of a POSIX *default*
/// ACL entry.
const OBJECT_INHERIT_ACE: u8 = 0x01;
const CONTAINER_INHERIT_ACE: u8 = 0x02;
/// Common access mask constants for NTFS files.
const FILE_ALL_ACCESS: u32 = 0x1F01FF;
const FILE_MODIFY: u32 = 0x1301BF;
const FILE_READ_EXECUTE: u32 = 0x1200A9;
const FILE_READ: u32 = 0x120089;
const FILE_WRITE: u32 = 0x120116;
/// Retrieve the DACL for a path. Returns the security descriptor pointer
/// (which must be freed with `LocalFree`) and the DACL pointer.
/// On failure returns `None`.
fn get_dacl(path: &Path) -> Option<(*mut u8, *const WIN_ACL)> {
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut p_sd: *mut u8 = ptr::null_mut();
let mut p_dacl: *mut WIN_ACL = ptr::null_mut();
let result = unsafe {
GetNamedSecurityInfoW(
wide.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
ptr::null_mut(),
ptr::null_mut(),
&mut p_dacl,
ptr::null_mut(),
&mut p_sd as *mut *mut u8 as *mut *mut core::ffi::c_void,
)
};
if result != 0 {
return None;
}
if p_dacl.is_null() {
if !p_sd.is_null() {
unsafe { LocalFree(p_sd as *mut core::ffi::c_void) };
}
return None;
}
Some((p_sd, p_dacl))
}
/// Returns `true` if the file at `path` has a DACL with at least one
/// explicit (non-inherited) ACE.
pub fn has_explicit_acl(path: &Path) -> bool {
let Some((p_sd, p_dacl)) = get_dacl(path) else {
return false;
};
let result = has_explicit_ace(p_dacl);
unsafe { LocalFree(p_sd as *mut core::ffi::c_void) };
result
}
/// Returns all explicit (non-inherited) ACEs as a formatted string.
/// Format: `allow:DOMAIN\User:full,deny:Guest:read,...`
/// Returns `None` if the DACL cannot be read or has no explicit ACEs.
pub fn format_acl(path: &Path) -> Option<String> {
let (p_sd, p_dacl) = get_dacl(path)?;
let result = collect_aces(p_dacl, is_explicit).join(",");
unsafe { LocalFree(p_sd as *mut core::ffi::c_void) };
if result.is_empty() {
None
} else {
Some(result)
}
}
/// Returns all inheritable ACEs as a formatted string, mirroring `format_acl`.
/// Inheritable ACEs are the Windows analogue of POSIX *default* ACL entries.
/// Returns `None` if the DACL cannot be read or has no inheritable ACEs.
pub fn format_default_acl(path: &Path) -> Option<String> {
let (p_sd, p_dacl) = get_dacl(path)?;
let result = collect_aces(p_dacl, is_inheritable).join(",");
unsafe { LocalFree(p_sd as *mut core::ffi::c_void) };
if result.is_empty() {
None
} else {
Some(result)
}
}
/// Returns `true` if the file at `path` has at least one inheritable ACE.
pub fn has_default_acl(path: &Path) -> bool {
let Some((p_sd, p_dacl)) = get_dacl(path) else {
return false;
};
let result = !collect_aces(p_dacl, is_inheritable).is_empty();
unsafe { LocalFree(p_sd as *mut core::ffi::c_void) };
result
}
/// Finds the explicit ACE whose trustee matches `trustee` and returns its
/// formatted representation (`allow:DOMAIN\User:full`). The match is
/// case-insensitive and accepts either the full `DOMAIN\Name` trustee or just
/// the bare account name.
pub fn find_acl_entry(path: &Path, trustee: &str) -> Option<String> {
find_entry_with(path, trustee, is_explicit)
}
/// Like [`find_acl_entry`], but searches inheritable (default) ACEs.
pub fn find_default_acl_entry(path: &Path, trustee: &str) -> Option<String> {
find_entry_with(path, trustee, is_inheritable)
}
fn find_entry_with(path: &Path, trustee: &str, keep: fn(u8) -> bool) -> Option<String> {
let (p_sd, p_dacl) = get_dacl(path)?;
let entries = collect_aces(p_dacl, keep);
unsafe { LocalFree(p_sd as *mut core::ffi::c_void) };
let trustee = trustee.trim();
entries.into_iter().find(|entry| entry_matches_trustee(entry, trustee))
}
/// An ACE is "explicit" (vs. inherited) when the `INHERITED_ACE` flag is unset.
fn is_explicit(ace_flags: u8) -> bool {
ace_flags & (INHERITED_ACE as u8) == 0
}
/// An ACE is "inheritable" when it carries an object- or container-inherit flag.
fn is_inheritable(ace_flags: u8) -> bool {
ace_flags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) != 0
}
/// Returns `true` if a formatted ACE (`type:trustee:perms`) is for the given
/// trustee, matching either the full `DOMAIN\Name` or the bare account name.
fn entry_matches_trustee(entry: &str, trustee: &str) -> bool {
let parts: Vec<&str> = entry.splitn(3, ':').collect();
if parts.len() != 3 {
return false;
}
let entry_trustee = parts[1];
entry_trustee.eq_ignore_ascii_case(trustee)
|| entry_trustee
.rsplit('\\')
.next()
.is_some_and(|name| name.eq_ignore_ascii_case(trustee))
}
/// Walk the ACEs in a DACL and return true if any are non-inherited.
fn has_explicit_ace(dacl: *const WIN_ACL) -> bool {
let acl = unsafe { &*dacl };
let ace_count = acl.AceCount as usize;
if ace_count == 0 {
return false;
}
let mut ace_ptr = unsafe { (dacl as *const u8).add(size_of::<WIN_ACL>()) };
for _ in 0..ace_count {
let header = unsafe { &*(ace_ptr as *const ACE_HEADER) };
if header.AceFlags & (INHERITED_ACE as u8) == 0 {
return true;
}
ace_ptr = unsafe { ace_ptr.add(header.AceSize as usize) };
}
false
}
/// Walk the ACEs in a DACL and format all entries kept by `keep` (a predicate
/// over the ACE flags). Returns one formatted string per matching ACE.
fn collect_aces(dacl: *const WIN_ACL, keep: fn(u8) -> bool) -> Vec<String> {
let acl = unsafe { &*dacl };
let ace_count = acl.AceCount as usize;
let mut entries = Vec::new();
if ace_count == 0 {
return entries;
}
let mut ace_ptr = unsafe { (dacl as *const u8).add(size_of::<WIN_ACL>()) };
for _ in 0..ace_count {
let header = unsafe { &*(ace_ptr as *const ACE_HEADER) };
if keep(header.AceFlags)
&& let Some(entry) = format_ace(ace_ptr, header.AceType) {
entries.push(entry);
}
ace_ptr = unsafe { ace_ptr.add(header.AceSize as usize) };
}
entries
}
/// Format a single ACE. The ACE layout for ACCESS_ALLOWED_ACE and
/// ACCESS_DENIED_ACE is identical: header (4 bytes), mask (4 bytes),
/// then the SID starting at offset 8.
fn format_ace(ace_ptr: *const u8, ace_type: u8) -> Option<String> {
let type_str = match ace_type {
ACE_TYPE_ACCESS_ALLOWED => "allow",
ACE_TYPE_ACCESS_DENIED => "deny",
_ => return None,
};
// Access mask is at offset 4 (right after ACE_HEADER).
let mask = unsafe { *(ace_ptr.add(4) as *const u32) };
// SID starts at offset 8.
let sid_ptr = unsafe { ace_ptr.add(8) };
let trustee = lookup_sid(sid_ptr as *mut core::ffi::c_void);
let permissions = format_access_mask(mask);
Some(format!("{}:{}:{}", type_str, trustee, permissions))
}
/// Resolve a SID to a `DOMAIN\Account` string. Falls back to a
/// raw `S-x-x-...` string if resolution fails.
fn lookup_sid(sid: *mut core::ffi::c_void) -> String {
let mut name_buf = [0u16; 256];
let mut domain_buf = [0u16; 256];
let mut name_len: u32 = name_buf.len() as u32;
let mut domain_len: u32 = domain_buf.len() as u32;
let mut sid_use: SID_NAME_USE = 0;
let ok = unsafe {
LookupAccountSidW(
ptr::null(),
sid,
name_buf.as_mut_ptr(),
&mut name_len,
domain_buf.as_mut_ptr(),
&mut domain_len,
&mut sid_use,
)
};
if ok != 0 {
let domain = String::from_utf16_lossy(&domain_buf[..domain_len as usize]);
let name = String::from_utf16_lossy(&name_buf[..name_len as usize]);
if domain.is_empty() {
name
} else {
format!(r"{}\{}", domain, name)
}
} else {
format_sid_fallback(sid)
}
}
/// Format a SID as `S-1-...` string when LookupAccountSid fails.
fn format_sid_fallback(sid: *mut core::ffi::c_void) -> String {
use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
let mut str_sid: *mut u16 = ptr::null_mut();
let ok = unsafe { ConvertSidToStringSidW(sid, &mut str_sid) };
if ok != 0 && !str_sid.is_null() {
let len = unsafe {
let mut p = str_sid;
while *p != 0 {
p = p.add(1);
}
p.offset_from(str_sid) as usize
};
let result = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(str_sid, len) });
unsafe { LocalFree(str_sid as *mut core::ffi::c_void) };
result
} else {
String::from("unknown")
}
}
/// Generic access rights. Inherit-only ACEs (the Windows analogue of POSIX
/// default ACL entries) frequently express permissions with these rather than
/// the specific `FILE_*` masks.
const GENERIC_ALL: u32 = 0x10000000;
const GENERIC_EXECUTE: u32 = 0x20000000;
const GENERIC_WRITE: u32 = 0x40000000;
const GENERIC_READ: u32 = 0x80000000;
/// Map an access mask to a human-readable permission string.
fn format_access_mask(mask: u32) -> String {
match mask {
FILE_ALL_ACCESS => return "full".to_string(),
FILE_MODIFY => return "modify".to_string(),
FILE_READ_EXECUTE => return "rx".to_string(),
FILE_READ => return "read".to_string(),
FILE_WRITE => return "write".to_string(),
_ => {}
}
// Map generic rights, common on inheritable ACEs.
if mask & (GENERIC_ALL | GENERIC_EXECUTE | GENERIC_WRITE | GENERIC_READ) != 0 {
if mask & GENERIC_ALL != 0 {
return "full".to_string();
}
let mut perms = String::new();
if mask & GENERIC_READ != 0 {
perms.push('r');
}
if mask & GENERIC_WRITE != 0 {
perms.push('w');
}
if mask & GENERIC_EXECUTE != 0 {
perms.push('x');
}
return perms;
}
format!("0x{:08x}", mask)
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_has_explicit_acl_on_temp() {
let tmp = env::temp_dir();
let _ = has_explicit_acl(&tmp);
}
#[test]
fn test_has_explicit_acl_nonexistent() {
let result = has_explicit_acl(Path::new(r"C:\nonexistent_file_fselect_test_12345"));
assert!(!result);
}
#[test]
fn test_format_acl_on_temp() {
let tmp = env::temp_dir();
// Should not panic; result depends on system config
let _ = format_acl(&tmp);
}
#[test]
fn test_format_acl_nonexistent() {
let result = format_acl(Path::new(r"C:\nonexistent_file_fselect_test_12345"));
assert!(result.is_none());
}
#[test]
fn test_format_access_mask_known() {
assert_eq!(format_access_mask(FILE_ALL_ACCESS), "full");
assert_eq!(format_access_mask(FILE_MODIFY), "modify");
assert_eq!(format_access_mask(FILE_READ_EXECUTE), "rx");
assert_eq!(format_access_mask(FILE_READ), "read");
assert_eq!(format_access_mask(FILE_WRITE), "write");
}
#[test]
fn test_format_access_mask_unknown() {
// A value with no recognized specific or generic bits falls back to hex.
let result = format_access_mask(0x00001234);
assert_eq!(result, "0x00001234");
}
#[test]
fn test_format_access_mask_generic() {
assert_eq!(format_access_mask(GENERIC_ALL), "full");
assert_eq!(format_access_mask(GENERIC_READ | GENERIC_EXECUTE), "rx");
assert_eq!(format_access_mask(GENERIC_READ), "r");
assert_eq!(format_access_mask(GENERIC_WRITE), "w");
assert_eq!(
format_access_mask(GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE),
"rwx"
);
// GENERIC_ALL dominates even when combined with other generic bits.
assert_eq!(format_access_mask(GENERIC_ALL | GENERIC_READ), "full");
}
#[test]
fn test_is_inheritable() {
assert!(is_inheritable(OBJECT_INHERIT_ACE));
assert!(is_inheritable(CONTAINER_INHERIT_ACE));
assert!(is_inheritable(OBJECT_INHERIT_ACE | INHERITED_ACE as u8));
assert!(!is_inheritable(0));
assert!(!is_inheritable(INHERITED_ACE as u8));
}
#[test]
fn test_default_acl_nonexistent() {
let path = Path::new(r"C:\nonexistent_fselect_default_acl_12345");
assert!(format_default_acl(path).is_none());
assert!(!has_default_acl(path));
assert!(find_default_acl_entry(path, "Administrators").is_none());
}
#[test]
fn test_entry_matches_trustee() {
let entry = r"allow:BUILTIN\Administrators:full";
// Full DOMAIN\Name match (case-insensitive).
assert!(entry_matches_trustee(entry, r"BUILTIN\Administrators"));
assert!(entry_matches_trustee(entry, r"builtin\administrators"));
// Bare account name match.
assert!(entry_matches_trustee(entry, "Administrators"));
assert!(entry_matches_trustee(entry, "administrators"));
// Non-matches.
assert!(!entry_matches_trustee(entry, "Guests"));
assert!(!entry_matches_trustee(entry, "BUILTIN"));
}
#[test]
fn test_entry_matches_trustee_no_domain() {
let entry = "deny:Everyone:write";
assert!(entry_matches_trustee(entry, "Everyone"));
assert!(entry_matches_trustee(entry, "everyone"));
assert!(!entry_matches_trustee(entry, "Anyone"));
}
#[test]
fn test_entry_matches_trustee_malformed() {
// A string that does not split into exactly three parts never matches.
assert!(!entry_matches_trustee("garbage", "anything"));
assert!(!entry_matches_trustee("allow:only_two", "only_two"));
}
#[test]
fn test_is_explicit() {
assert!(is_explicit(0));
assert!(!is_explicit(INHERITED_ACE as u8));
}
#[test]
fn test_find_acl_entry_nonexistent() {
assert!(find_acl_entry(Path::new(r"C:\nonexistent_fselect_acl_12345"), "Administrators").is_none());
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Windows file attribute detection, the closest equivalent to Linux
//! extended file flags (the `chattr`/`lsattr` flags exposed on Linux via
//! `FS_IOC_GETFLAGS`).
//!
//! Each NTFS file attribute is mapped to a single letter, mirroring the
//! letter-based formatting used by the Linux `extattrs` module so the
//! `extattrs` / `has_extattrs` / `has_extattr` query fields behave
//! consistently across platforms.
use std::fs::Metadata;
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_READONLY: u32 = 0x1;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
const FILE_ATTRIBUTE_SYSTEM: u32 = 0x4;
const FILE_ATTRIBUTE_ARCHIVE: u32 = 0x20;
const FILE_ATTRIBUTE_TEMPORARY: u32 = 0x100;
const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x200;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
const FILE_ATTRIBUTE_COMPRESSED: u32 = 0x800;
const FILE_ATTRIBUTE_OFFLINE: u32 = 0x1000;
const FILE_ATTRIBUTE_NOT_CONTENT_INDEXED: u32 = 0x2000;
const FILE_ATTRIBUTE_ENCRYPTED: u32 = 0x4000;
const FILE_ATTRIBUTE_INTEGRITY_STREAM: u32 = 0x8000;
/// Mapping of NTFS file attributes to single-letter flags. The letters follow
/// the conventions of the Windows `attrib` command where applicable
/// (R/A/S/H/I), with additional letters for the remaining attributes.
const FLAG_LETTERS: &[(u32, char)] = &[
(FILE_ATTRIBUTE_READONLY, 'R'),
(FILE_ATTRIBUTE_HIDDEN, 'H'),
(FILE_ATTRIBUTE_SYSTEM, 'S'),
(FILE_ATTRIBUTE_ARCHIVE, 'A'),
(FILE_ATTRIBUTE_TEMPORARY, 'T'),
(FILE_ATTRIBUTE_SPARSE_FILE, 'P'),
(FILE_ATTRIBUTE_REPARSE_POINT, 'L'),
(FILE_ATTRIBUTE_COMPRESSED, 'C'),
(FILE_ATTRIBUTE_OFFLINE, 'O'),
(FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, 'I'),
(FILE_ATTRIBUTE_ENCRYPTED, 'E'),
(FILE_ATTRIBUTE_INTEGRITY_STREAM, 'V'),
];
/// Returns the raw NTFS file attribute bitmask for the given metadata.
pub fn get_attrs(meta: &Metadata) -> u32 {
meta.file_attributes()
}
/// Formats the mapped file attributes as a string of single-letter flags,
/// e.g. `"RA"` for a read-only file with the archive bit set. Returns an
/// empty string if none of the mapped attributes are set.
pub fn format_attrs(attrs: u32) -> String {
let mut result = String::new();
for &(flag, letter) in FLAG_LETTERS {
if attrs & flag != 0 {
result.push(letter);
}
}
result
}
/// Returns `true` if any of the mapped attributes is set.
pub fn has_any_attr(attrs: u32) -> bool {
FLAG_LETTERS.iter().any(|&(flag, _)| attrs & flag != 0)
}
/// Returns `true` if the single-letter attribute flag `attr` is set. The
/// lookup is case-sensitive to match the distinct upper-case letters used
/// in the flag table.
pub fn has_attr(attrs: u32, attr: &str) -> bool {
let attr = attr.trim();
if attr.chars().count() != 1 {
return false;
}
let ch = attr.chars().next().unwrap();
for &(flag, letter) in FLAG_LETTERS {
if letter == ch {
return attrs & flag != 0;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_attrs_empty() {
assert_eq!(format_attrs(0), "");
}
#[test]
fn test_format_attrs_readonly() {
assert_eq!(format_attrs(FILE_ATTRIBUTE_READONLY), "R");
}
#[test]
fn test_format_attrs_multiple_in_table_order() {
let attrs = FILE_ATTRIBUTE_ARCHIVE | FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_HIDDEN;
// Order follows FLAG_LETTERS, not the order of the bits passed in.
assert_eq!(format_attrs(attrs), "RHA");
}
#[test]
fn test_has_any_attr() {
assert!(!has_any_attr(0));
assert!(has_any_attr(FILE_ATTRIBUTE_COMPRESSED));
}
#[test]
fn test_has_attr() {
let attrs = FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_COMPRESSED;
assert!(has_attr(attrs, "R"));
assert!(has_attr(attrs, "C"));
assert!(has_attr(attrs, " C ")); // trimmed
assert!(!has_attr(attrs, "H"));
}
#[test]
fn test_has_attr_invalid() {
assert!(!has_attr(FILE_ATTRIBUTE_READONLY, ""));
assert!(!has_attr(FILE_ATTRIBUTE_READONLY, "RA"));
assert!(!has_attr(FILE_ATTRIBUTE_READONLY, "Z"));
// Letters are case-sensitive.
assert!(!has_attr(FILE_ATTRIBUTE_READONLY, "r"));
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Windows extended attribute detection via NTFS Alternate Data Streams.
//!
//! On Windows, the closest equivalent to Unix extended attributes are
//! NTFS Alternate Data Streams (ADS). This module uses `FindFirstStreamW`
//! / `FindNextStreamW` to enumerate them.
use std::path::Path;
use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{
FindClose, FindFirstStreamW, FindNextStreamW, FindStreamInfoStandard, WIN32_FIND_STREAM_DATA,
};
use std::os::windows::ffi::OsStrExt;
/// Returns `true` if the file at `path` has any non-default alternate data streams.
pub fn has_any_ads(path: &Path) -> bool {
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut stream_data: WIN32_FIND_STREAM_DATA = unsafe { std::mem::zeroed() };
let handle: HANDLE = unsafe {
FindFirstStreamW(
wide.as_ptr(),
FindStreamInfoStandard,
&mut stream_data as *mut _ as *mut core::ffi::c_void,
0,
)
};
if handle == INVALID_HANDLE_VALUE {
return false;
}
// The first stream is typically the default data stream "::$DATA".
// Any additional stream indicates an ADS (extended attribute equivalent).
loop {
let name = stream_name_from_data(&stream_data);
if name != "::$DATA" {
unsafe { FindClose(handle) };
return true;
}
let ok = unsafe {
FindNextStreamW(
handle,
&mut stream_data as *mut _ as *mut core::ffi::c_void,
)
};
if ok == 0 {
break;
}
}
unsafe { FindClose(handle) };
false
}
/// Returns the number of non-default alternate data streams on the file at `path`.
pub fn count_ads(path: &Path) -> usize {
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut stream_data: WIN32_FIND_STREAM_DATA = unsafe { std::mem::zeroed() };
let handle: HANDLE = unsafe {
FindFirstStreamW(
wide.as_ptr(),
FindStreamInfoStandard,
&mut stream_data as *mut _ as *mut core::ffi::c_void,
0,
)
};
if handle == INVALID_HANDLE_VALUE {
return 0;
}
let mut count = 0;
loop {
let name = stream_name_from_data(&stream_data);
if name != "::$DATA" {
count += 1;
}
let ok = unsafe {
FindNextStreamW(
handle,
&mut stream_data as *mut _ as *mut core::ffi::c_void,
)
};
if ok == 0 {
break;
}
}
unsafe { FindClose(handle) };
count
}
/// Returns `true` if the file at `path` has an alternate data stream with the
/// given `name`. The name should be provided without the `:` prefix or `:$DATA`
/// suffix (e.g., just `"Zone.Identifier"`).
pub fn has_named_ads(path: &Path, name: &str) -> bool {
let target = format!(":{}:$DATA", name);
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut stream_data: WIN32_FIND_STREAM_DATA = unsafe { std::mem::zeroed() };
let handle: HANDLE = unsafe {
FindFirstStreamW(
wide.as_ptr(),
FindStreamInfoStandard,
&mut stream_data as *mut _ as *mut core::ffi::c_void,
0,
)
};
if handle == INVALID_HANDLE_VALUE {
return false;
}
loop {
let stream = stream_name_from_data(&stream_data);
if stream.eq_ignore_ascii_case(&target) {
unsafe { FindClose(handle) };
return true;
}
let ok = unsafe {
FindNextStreamW(
handle,
&mut stream_data as *mut _ as *mut core::ffi::c_void,
)
};
if ok == 0 {
break;
}
}
unsafe { FindClose(handle) };
false
}
/// Reads the content of a named alternate data stream as a UTF-8 string.
/// Returns `None` if the stream does not exist or cannot be read as UTF-8.
pub fn read_named_ads(path: &Path, name: &str) -> Option<String> {
use std::fs::File;
use std::io::Read;
let stream_path = format!("{}:{}", path.display(), name);
let mut file = File::open(&stream_path).ok()?;
let mut contents = String::new();
file.read_to_string(&mut contents).ok()?;
Some(contents)
}
/// Extract the stream name from a `WIN32_FIND_STREAM_DATA` as a Rust `String`.
fn stream_name_from_data(data: &WIN32_FIND_STREAM_DATA) -> String {
let len = data
.cStreamName
.iter()
.position(|&c| c == 0)
.unwrap_or(data.cStreamName.len());
String::from_utf16_lossy(&data.cStreamName[..len])
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_has_any_ads_on_temp() {
let tmp = env::temp_dir();
// Should not panic
let _ = has_any_ads(&tmp);
}
#[test]
fn test_has_any_ads_nonexistent() {
let result = has_any_ads(Path::new(r"C:\nonexistent_file_fselect_test_xattr_12345"));
assert!(!result);
}
#[test]
fn test_has_named_ads_nonexistent() {
let result = has_named_ads(
Path::new(r"C:\nonexistent_file_fselect_test_xattr_12345"),
"Zone.Identifier",
);
assert!(!result);
}
}