chore: import upstream snapshot with attribution
@@ -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
|
||||
@@ -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
|
||||
@@ -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/**
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM rust:latest
|
||||
|
||||
WORKDIR /usr/src/fselect
|
||||
COPY . .
|
||||
|
||||
RUN cargo install --locked --path .
|
||||
|
||||
CMD ["cargo", "test", "--locked" , "--verbose", "--all"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,335 @@
|
||||
# fselect
|
||||
Find files with SQL-like queries
|
||||
|
||||
[](https://crates.io/crates/fselect)
|
||||
[](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) · [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) · [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
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`jhspetersson/fselect`
|
||||
- 原始仓库:https://github.com/jhspetersson/fselect
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
@@ -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 |
|
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 |
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(®ex::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);
|
||||
}
|
||||
}
|
||||
@@ -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(®ex::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(®ex::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("^") + ®ex::escape(&file_path.to_string_lossy());
|
||||
if !regexp.starts_with("^") {
|
||||
pattern = pattern.add("/([^/]+/)*");
|
||||
pattern = pattern.add(".*");
|
||||
} else {
|
||||
pattern = pattern.add("/");
|
||||
}
|
||||
|
||||
pattern = pattern.add(®exp.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("^") + ®ex::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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod docker;
|
||||
pub(crate) mod hg;
|
||||
@@ -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 "));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("&"),
|
||||
'<' => result.push_str("<"),
|
||||
'>' => result.push_str(">"),
|
||||
'"' => result.push_str("""),
|
||||
_ => 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>"), "<script>alert(1)</script>");
|
||||
assert_eq!(escape_html("a&b"), "a&b");
|
||||
assert_eq!(escape_html("\"hello\""), ""hello"");
|
||||
}
|
||||
}
|
||||
@@ -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"}]"#,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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+0370–U+03FF
|
||||
// Greek Extended: U+1F00–U+1FFF
|
||||
matches!(c, '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||