@@ -9,8 +9,8 @@ use async_once_cell::OnceCell;
99use bollard:: Docker ;
1010use distant_core:: protocol:: {
1111 ChangeKind , DirEntry , Environment , FileType , Metadata , PROTOCOL_VERSION , Permissions ,
12- ProcessId , PtySize , SearchId , SearchQuery , SearchQueryTarget , SetPermissionsOptions ,
13- SystemInfo , UnixMetadata , Version ,
12+ ProcessId , PtySize , SearchId , SearchQuery , SearchQueryMatch , SearchQueryTarget ,
13+ SetPermissionsOptions , SystemInfo , UnixMetadata , Version ,
1414} ;
1515use distant_core:: { Api , Ctx } ;
1616use tokio:: sync:: RwLock ;
@@ -128,6 +128,122 @@ impl DockerApi {
128128 ) ) )
129129 }
130130 }
131+
132+ /// Perform a search using the Docker tar API as a fallback.
133+ ///
134+ /// On Windows nanoserver, exec-based search tools (`findstr`, `dir /s /b`) cannot
135+ /// access paths created via the Docker tar API due to `ContainerUser` permission
136+ /// restrictions. This method reads directory listings and file contents through
137+ /// the Docker archive API and performs matching in Rust.
138+ async fn tar_based_search ( & self , query : & SearchQuery ) -> Vec < SearchQueryMatch > {
139+ use distant_core:: protocol:: {
140+ SearchQueryContentsMatch , SearchQueryMatchData , SearchQueryPathMatch ,
141+ SearchQuerySubmatch ,
142+ } ;
143+
144+ let mut matches = Vec :: new ( ) ;
145+
146+ let path = query
147+ . paths
148+ . first ( )
149+ . map ( |p| p. to_string_lossy ( ) . to_string ( ) )
150+ . unwrap_or_else ( || "." . to_string ( ) ) ;
151+
152+ // List all entries in the search directory via tar
153+ let entries = match utils:: tar_list_dir ( & self . client , & self . container , & path) . await {
154+ Ok ( e) => e,
155+ Err ( _) => return matches,
156+ } ;
157+
158+ match query. target {
159+ SearchQueryTarget :: Path => {
160+ for ( _entry_type, entry_path, _size, _mtime) in & entries {
161+ let filename = std:: path:: Path :: new ( entry_path)
162+ . file_name ( )
163+ . map ( |n| n. to_string_lossy ( ) . to_string ( ) )
164+ . unwrap_or_default ( ) ;
165+
166+ if search:: condition_matches ( & query. condition , & filename) {
167+ // Reconstruct the full container path.
168+ // tar_list_dir returns paths like "dirname/file.txt" — strip
169+ // the first component and any leading separator so Path::join
170+ // treats the remainder as relative (not absolute).
171+ let relative = entry_path
172+ . strip_prefix (
173+ std:: path:: Path :: new ( entry_path)
174+ . components ( )
175+ . next ( )
176+ . map ( |c| c. as_os_str ( ) . to_string_lossy ( ) . to_string ( ) )
177+ . unwrap_or_default ( )
178+ . as_str ( ) ,
179+ )
180+ . unwrap_or ( entry_path)
181+ . trim_start_matches ( [ '/' , '\\' ] ) ;
182+ let full_path = std:: path:: Path :: new ( & path) . join ( relative) ;
183+ matches. push ( SearchQueryMatch :: Path ( SearchQueryPathMatch {
184+ path : full_path,
185+ submatches : vec ! [ SearchQuerySubmatch {
186+ r#match: SearchQueryMatchData :: Text ( filename) ,
187+ start: 0 ,
188+ end: 0 ,
189+ } ] ,
190+ } ) ) ;
191+ }
192+ }
193+ }
194+ SearchQueryTarget :: Contents => {
195+ for ( entry_type, entry_path, _size, _mtime) in & entries {
196+ // Skip directories — only search file contents
197+ if * entry_type == tar:: EntryType :: Directory {
198+ continue ;
199+ }
200+
201+ // Reconstruct the full container path for this file
202+ let relative = entry_path
203+ . strip_prefix (
204+ std:: path:: Path :: new ( entry_path)
205+ . components ( )
206+ . next ( )
207+ . map ( |c| c. as_os_str ( ) . to_string_lossy ( ) . to_string ( ) )
208+ . unwrap_or_default ( )
209+ . as_str ( ) ,
210+ )
211+ . unwrap_or ( entry_path)
212+ . trim_start_matches ( [ '/' , '\\' ] ) ;
213+ let full_path = std:: path:: Path :: new ( & path) . join ( relative) ;
214+ let full_path_str = full_path. to_string_lossy ( ) . to_string ( ) ;
215+
216+ // Read file contents via tar API
217+ let data =
218+ match utils:: tar_read_file ( & self . client , & self . container , & full_path_str)
219+ . await
220+ {
221+ Ok ( d) => d,
222+ Err ( _) => continue ,
223+ } ;
224+
225+ let text = String :: from_utf8_lossy ( & data) ;
226+ for ( line_num, line) in text. lines ( ) . enumerate ( ) {
227+ if search:: condition_matches ( & query. condition , line) {
228+ matches. push ( SearchQueryMatch :: Contents ( SearchQueryContentsMatch {
229+ path : full_path. clone ( ) ,
230+ lines : SearchQueryMatchData :: Text ( line. to_string ( ) ) ,
231+ line_number : ( line_num + 1 ) as u64 ,
232+ absolute_offset : 0 ,
233+ submatches : vec ! [ SearchQuerySubmatch {
234+ r#match: SearchQueryMatchData :: Text ( line. to_string( ) ) ,
235+ start: 0 ,
236+ end: 0 ,
237+ } ] ,
238+ } ) ) ;
239+ }
240+ }
241+ }
242+ }
243+ }
244+
245+ matches
246+ }
131247}
132248
133249impl Api for DockerApi {
@@ -525,24 +641,30 @@ impl Api for DockerApi {
525641 async move {
526642 let path_str = path. to_string_lossy ( ) . to_string ( ) ;
527643
528- let cmd = match self . family {
529- DockerFamily :: Unix => {
530- if force {
531- format ! ( "rm -rf '{}'" , path_str)
532- } else {
533- format ! ( "rm -r '{}'" , path_str)
534- }
535- }
536- DockerFamily :: Windows => {
537- // Try rmdir first (for dirs), fall back to del (for files)
538- format ! (
539- "rmdir /s /q \" {}\" 2>nul & if errorlevel 1 del /f \" {}\" " ,
540- path_str, path_str
541- )
542- }
543- } ;
644+ if self . family == DockerFamily :: Unix {
645+ let cmd = if force {
646+ format ! ( "rm -rf '{}'" , path_str)
647+ } else {
648+ format ! ( "rm -r '{}'" , path_str)
649+ } ;
650+ return self . run_shell_cmd_stdout ( & cmd) . await . map ( |_| ( ) ) ;
651+ }
544652
545- self . run_shell_cmd_stdout ( & cmd) . await . map ( |_| ( ) )
653+ // Windows: nanoserver's cmd.exe has limited compound operators —
654+ // `||` is not supported and `& if errorlevel 1` has unreliable
655+ // errorlevel propagation. Try `rmdir /s /q` first (handles
656+ // directories), then `del /f` for files, as separate exec calls.
657+ let rmdir_cmd = format ! ( "rmdir /s /q \" {}\" " , path_str) ;
658+ if self
659+ . run_shell_cmd ( & rmdir_cmd)
660+ . await
661+ . is_ok_and ( |o| o. success ( ) )
662+ {
663+ return Ok ( ( ) ) ;
664+ }
665+
666+ let del_cmd = format ! ( "del /f /q \" {}\" " , path_str) ;
667+ self . run_shell_cmd_stdout ( & del_cmd) . await . map ( |_| ( ) )
546668 }
547669 }
548670
@@ -627,18 +749,21 @@ impl Api for DockerApi {
627749 async move {
628750 let path_str = path. to_string_lossy ( ) . to_string ( ) ;
629751
630- // Try exec-based check first
631- let cmd = match self . family {
632- DockerFamily :: Unix => format ! ( "test -e '{}'" , path_str) ,
633- DockerFamily :: Windows => {
634- format ! ( "if exist \" {}\" (exit 0) else (exit 1)" , path_str)
635- }
636- } ;
752+ if self . family == DockerFamily :: Windows {
753+ // On Windows nanoserver, the exec-based `if exist` check is unreliable:
754+ // it cannot see directories created via the Docker tar API (used as
755+ // fallback when `mkdir` fails due to ContainerUser permissions).
756+ // Use the tar-based check as the primary method — Docker's archive
757+ // API correctly reports existence regardless of how the path was created,
758+ // and returns 404 for truly deleted paths.
759+ return Ok ( utils:: tar_path_exists ( & self . client , & self . container , & path_str) . await ) ;
760+ }
637761
762+ // Unix: use exec-based check with tar fallback for infrastructure errors
763+ let cmd = format ! ( "test -e '{}'" , path_str) ;
638764 match self . run_shell_cmd ( & cmd) . await {
639765 Ok ( output) => Ok ( output. success ( ) ) ,
640766 Err ( _) => {
641- // Fallback to tar-based existence check
642767 Ok ( utils:: tar_path_exists ( & self . client , & self . container , & path_str) . await )
643768 }
644769 }
@@ -737,26 +862,28 @@ impl Api for DockerApi {
737862 query : SearchQuery ,
738863 ) -> impl std:: future:: Future < Output = io:: Result < SearchId > > + Send {
739864 async move {
740- if !self . search_tools . has_any ( ) {
741- return Err ( io:: Error :: new (
742- io:: ErrorKind :: Unsupported ,
743- "No search tools available in this container. \
744- Install ripgrep, grep, or find for search support.",
745- ) ) ;
746- }
747-
748- let cmd = search:: build_search_command ( & query, & self . search_tools , self . family ) ?;
749865 let search_id: SearchId = rand:: random ( ) ;
750866
751- // Run the search command
752- let output = self . run_shell_cmd ( & cmd) . await ?;
753- let stdout = output. stdout_str ( ) ;
867+ // Try exec-based search first if tools are available
868+ let mut matches = Vec :: new ( ) ;
869+ if self . search_tools . has_any ( )
870+ && let Ok ( cmd) =
871+ search:: build_search_command ( & query, & self . search_tools , self . family )
872+ && let Ok ( output) = self . run_shell_cmd ( & cmd) . await
873+ {
874+ let stdout = output. stdout_str ( ) ;
875+ matches = match query. target {
876+ SearchQueryTarget :: Contents => search:: parse_contents_matches ( & stdout) ,
877+ SearchQueryTarget :: Path => search:: parse_path_matches ( & stdout) ,
878+ } ;
879+ }
754880
755- // Parse results based on search target
756- let matches = match query. target {
757- SearchQueryTarget :: Contents => search:: parse_contents_matches ( & stdout) ,
758- SearchQueryTarget :: Path => search:: parse_path_matches ( & stdout) ,
759- } ;
881+ // On Windows nanoserver, exec-based search tools (findstr, dir) cannot
882+ // access paths created via the Docker tar API. Fall back to a tar-based
883+ // search that reads files through the Docker archive API.
884+ if matches. is_empty ( ) && self . family == DockerFamily :: Windows {
885+ matches = self . tar_based_search ( & query) . await ;
886+ }
760887
761888 // Send results via reply
762889 use distant_core:: protocol:: Response ;
@@ -870,12 +997,20 @@ impl Api for DockerApi {
870997 let mut processes = self . processes . write ( ) . await ;
871998 match processes. get_mut ( & id) {
872999 Some ( process) => {
873- // Send kill signal via channel
1000+ // Take the kill channel to send the signal. We do NOT remove the
1001+ // map entry here — the reader task's cleanup closure is the sole
1002+ // owner of removal. Removing here creates a race: if a new process
1003+ // reuses the same ProcessId before the reader finishes, the stale
1004+ // cleanup would delete the new entry.
8741005 if let Some ( kill_tx) = process. kill_tx . take ( ) {
8751006 let _ = kill_tx. send ( ( ) ) . await ;
1007+ Ok ( ( ) )
1008+ } else {
1009+ Err ( io:: Error :: new (
1010+ io:: ErrorKind :: InvalidInput ,
1011+ format ! ( "Process {} has already been killed" , id) ,
1012+ ) )
8761013 }
877- processes. remove ( & id) ;
878- Ok ( ( ) )
8791014 }
8801015 None => Err ( io:: Error :: new (
8811016 io:: ErrorKind :: NotFound ,
0 commit comments