@@ -1551,6 +1551,56 @@ fn local_name(raw: &[u8]) -> String {
15511551 }
15521552}
15531553
1554+ /// Canonical lowercase key for an ownCloud/Nextcloud `oc:checksums`
1555+ /// algorithm label, matching every `StorageProvider::checksum()` impl
1556+ /// and the `hashsum` / `lsjson --hash` consumers. Unknown labels degrade
1557+ /// to a lowercased, separator-stripped form (still a real server-side
1558+ /// digest, just an exotic algo) rather than being dropped.
1559+ fn canonical_checksum_key ( algo : & str ) -> String {
1560+ let norm: String = algo
1561+ . chars ( )
1562+ . filter ( |c| c. is_ascii_alphanumeric ( ) )
1563+ . collect :: < String > ( )
1564+ . to_ascii_uppercase ( ) ;
1565+ match norm. as_str ( ) {
1566+ "SHA256" => "sha256" ,
1567+ "SHA512" => "sha512" ,
1568+ "SHA384" => "sha384" ,
1569+ "SHA1" => "sha1" ,
1570+ "MD5" => "md5" ,
1571+ "CRC32" => "crc32" ,
1572+ "ADLER32" => "adler32" ,
1573+ _ => return norm. to_ascii_lowercase ( ) ,
1574+ }
1575+ . to_string ( )
1576+ }
1577+
1578+ /// Parse an `<oc:checksums><oc:checksum>` payload into canonical
1579+ /// `{key: hexdigest}` pairs. The element is a single string of
1580+ /// whitespace-separated `ALGO:HEXDIGEST` tokens, e.g.
1581+ /// `"SHA1:f1d2d2... MD5:900150... ADLER32:024d0127"`. Tokens without a
1582+ /// `:`, with an empty digest, or with a non-hex digest are skipped so a
1583+ /// malformed entry can never poison the map. Returns an empty map for
1584+ /// every WebDAV server that does not emit `oc:checksums` (the
1585+ /// server-side-or-omit contract: no content is ever downloaded).
1586+ fn parse_oc_checksums ( raw : & str ) -> HashMap < String , String > {
1587+ let mut out = HashMap :: new ( ) ;
1588+ for token in raw. split_whitespace ( ) {
1589+ let Some ( ( algo, digest) ) = token. split_once ( ':' ) else {
1590+ continue ;
1591+ } ;
1592+ let digest = digest. trim ( ) . to_ascii_lowercase ( ) ;
1593+ if digest. is_empty ( ) || !digest. bytes ( ) . all ( |b| b. is_ascii_hexdigit ( ) ) {
1594+ continue ;
1595+ }
1596+ let key = canonical_checksum_key ( algo) ;
1597+ if !key. is_empty ( ) {
1598+ out. entry ( key) . or_insert ( digest) ;
1599+ }
1600+ }
1601+ out
1602+ }
1603+
15541604#[ async_trait]
15551605impl StorageProvider for WebDavProvider {
15561606 fn as_any_mut ( & mut self ) -> & mut dyn std:: any:: Any {
@@ -2372,6 +2422,71 @@ impl StorageProvider for WebDavProvider {
23722422 }
23732423 }
23742424
2425+ fn supports_checksum ( & self ) -> bool {
2426+ // ownCloud/Nextcloud expose server-side digests via the
2427+ // `oc:checksums` PROPFIND prop; every other WebDAV server simply
2428+ // omits it, in which case `checksum()` returns an empty map and
2429+ // consumers omit / fall back. The probe is a metadata PROPFIND,
2430+ // never a content download: the server-side-or-omit contract.
2431+ true
2432+ }
2433+
2434+ async fn checksum ( & mut self , path : & str ) -> Result < HashMap < String , String > , ProviderError > {
2435+ if !self . connected {
2436+ return Err ( ProviderError :: NotConnected ) ;
2437+ }
2438+
2439+ const PROPFIND_BODY : & str = r#"<?xml version="1.0" encoding="utf-8"?>
2440+ <d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
2441+ <d:prop>
2442+ <oc:checksums/>
2443+ </d:prop>
2444+ </d:propfind>"# ;
2445+
2446+ // Mirror `stat()`'s file-first / collection-retry: a file must be
2447+ // requested without a trailing slash, while a slash-less
2448+ // collection can be 301'd by Apache to a scheme-downgraded URL
2449+ // that strips auth (see `collection_path`).
2450+ let collection_form = Self :: collection_path ( path) ;
2451+ let mut attempts: Vec < & str > = vec ! [ path] ;
2452+ if collection_form != path {
2453+ attempts. push ( collection_form. as_str ( ) ) ;
2454+ }
2455+
2456+ for attempt in attempts {
2457+ let response = self
2458+ . request ( webdav_methods:: propfind ( ) , attempt)
2459+ . header ( "Depth" , "0" )
2460+ . header ( "Content-Type" , "application/xml" )
2461+ . body ( PROPFIND_BODY )
2462+ . send ( )
2463+ . await
2464+ . map_err ( |e| ProviderError :: NetworkError ( e. to_string ( ) ) ) ?;
2465+
2466+ match response. status ( ) {
2467+ StatusCode :: OK | StatusCode :: MULTI_STATUS => {
2468+ let xml = response
2469+ . text ( )
2470+ . await
2471+ . map_err ( |e| ProviderError :: ParseError ( e. to_string ( ) ) ) ?;
2472+ let props = self . extract_xml_properties ( & xml) ;
2473+ return Ok ( props
2474+ . get ( "checksum" )
2475+ . map ( |s| parse_oc_checksums ( s) )
2476+ . unwrap_or_default ( ) ) ;
2477+ }
2478+ // Ambiguous path type: retry in collection form.
2479+ StatusCode :: NOT_FOUND | StatusCode :: UNAUTHORIZED => continue ,
2480+ // Any other status: the server cannot answer the prop.
2481+ // Treat as "no server-side hash" (omit) rather than an
2482+ // error that would fail a listing or trigger a download.
2483+ _ => return Ok ( HashMap :: new ( ) ) ,
2484+ }
2485+ }
2486+
2487+ Ok ( HashMap :: new ( ) )
2488+ }
2489+
23752490 async fn size ( & mut self , path : & str ) -> Result < u64 , ProviderError > {
23762491 let entry = self . stat ( path) . await ?;
23772492 Ok ( entry. size )
@@ -3211,4 +3326,48 @@ mod tests {
32113326 t. server_root = Some ( "/" . to_string ( ) ) ;
32123327 assert_eq ! ( t. resolve_root( "/aeroftp-utest" ) , "/aeroftp-utest" ) ;
32133328 }
3329+
3330+ #[ test]
3331+ fn oc_checksums_parsed_to_canonical_lowercase_keys ( ) {
3332+ // Real Nextcloud/ownCloud shape: space-separated ALGO:HEX tokens.
3333+ let m = parse_oc_checksums (
3334+ "SHA1:f1d2d2f924e986ac86fdf7b36c94bcdf32beec15 \
3335+ MD5:900150983cd24fb0d6963f7d28e17f72 ADLER32:024d0127",
3336+ ) ;
3337+ assert_eq ! (
3338+ m. get( "sha1" ) . map( String :: as_str) ,
3339+ Some ( "f1d2d2f924e986ac86fdf7b36c94bcdf32beec15" )
3340+ ) ;
3341+ assert_eq ! (
3342+ m. get( "md5" ) . map( String :: as_str) ,
3343+ Some ( "900150983cd24fb0d6963f7d28e17f72" )
3344+ ) ;
3345+ assert_eq ! ( m. get( "adler32" ) . map( String :: as_str) , Some ( "024d0127" ) ) ;
3346+ // No canonical key is upper-cased or dash-separated.
3347+ assert ! ( m. keys( ) . all( |k| k == & k. to_ascii_lowercase( ) ) ) ;
3348+ }
3349+
3350+ #[ test]
3351+ fn oc_checksums_canonicalises_separators_and_case ( ) {
3352+ let m = parse_oc_checksums ( "SHA-256:ABCDEF01 sha512:00FF" ) ;
3353+ assert_eq ! ( m. get( "sha256" ) . map( String :: as_str) , Some ( "abcdef01" ) ) ;
3354+ assert_eq ! ( m. get( "sha512" ) . map( String :: as_str) , Some ( "00ff" ) ) ;
3355+ }
3356+
3357+ #[ test]
3358+ fn oc_checksums_skips_malformed_and_empty ( ) {
3359+ // No colon, empty digest, non-hex digest, and the empty string.
3360+ assert ! ( parse_oc_checksums( "" ) . is_empty( ) ) ;
3361+ assert ! ( parse_oc_checksums( "SHA1 MD5: SHA256:zz_not_hex" ) . is_empty( ) ) ;
3362+ // A single good token among malformed ones still survives.
3363+ let m = parse_oc_checksums ( "garbage MD5:0a1b BAD:" ) ;
3364+ assert_eq ! ( m. len( ) , 1 ) ;
3365+ assert_eq ! ( m. get( "md5" ) . map( String :: as_str) , Some ( "0a1b" ) ) ;
3366+ }
3367+
3368+ #[ test]
3369+ fn unknown_algo_degrades_not_dropped ( ) {
3370+ let m = parse_oc_checksums ( "WHIRLPOOL:dead" ) ;
3371+ assert_eq ! ( m. get( "whirlpool" ) . map( String :: as_str) , Some ( "dead" ) ) ;
3372+ }
32143373}
0 commit comments