Skip to content

Commit a474cd2

Browse files
cwoolumchaynabors
authored andcommitted
feat: add Windows support for os shim (#1714)
Co-authored-by: Woolum <woolumc@amazon.com>
1 parent 3bab2c4 commit a474cd2

File tree

6 files changed

+417
-39
lines changed

6 files changed

+417
-39
lines changed

crates/fig_os_shim/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ tokio = { workspace = true, features = ["fs"] }
1818
sysinfo.workspace = true
1919
nix.workspace = true
2020

21+
[target.'cfg(windows)'.dependencies]
22+
sysinfo.workspace = true
23+
2124
[lints]
2225
workspace = true
2326

crates/fig_os_shim/src/fs.rs

Lines changed: 285 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
use std::collections::HashMap;
22
use std::fs::Permissions;
33
use std::io;
4+
#[cfg(unix)]
45
use std::os::unix::ffi::OsStrExt;
5-
use std::path::{
6-
Path,
7-
PathBuf,
8-
};
9-
use std::sync::{
10-
Arc,
11-
Mutex,
12-
};
6+
use std::path::{Path, PathBuf};
7+
use std::sync::{Arc, Mutex};
138

149
use tempfile::TempDir;
1510
use tokio::fs;
@@ -22,10 +17,7 @@ pub struct Fs(inner::Inner);
2217
mod inner {
2318
use std::collections::HashMap;
2419
use std::path::PathBuf;
25-
use std::sync::{
26-
Arc,
27-
Mutex,
28-
};
20+
use std::sync::{Arc, Mutex};
2921

3022
use tempfile::TempDir;
3123

@@ -304,6 +296,52 @@ impl Fs {
304296
}
305297
}
306298

299+
/// Creates a new symbolic link on the filesystem.
300+
///
301+
/// The `link` path will be a symbolic link pointing to the `original` path.
302+
///
303+
/// This function works for both files and directories on all platforms.
304+
/// On Windows, it automatically detects whether the target is a file or directory
305+
/// and uses the appropriate system call.
306+
///
307+
/// This is a proxy to [`tokio::fs::symlink_file`] or [`tokio::fs::symlink_dir`] on Windows,
308+
/// and [`tokio::fs::symlink`] on Unix.
309+
#[cfg(windows)]
310+
pub async fn symlink(&self, original: impl AsRef<Path>, link: impl AsRef<Path>) -> io::Result<()> {
311+
use inner::Inner;
312+
313+
let original_path = original.as_ref();
314+
315+
// Check if the original path exists and is a directory
316+
let is_dir = if let Ok(metadata) = std::fs::metadata(original_path) {
317+
metadata.is_dir()
318+
} else {
319+
// If the path doesn't exist, check if it ends with a path separator
320+
// This is a heuristic and not foolproof
321+
original_path.to_string_lossy().ends_with(['/', '\\'])
322+
};
323+
324+
match &self.0 {
325+
Inner::Real => {
326+
if is_dir {
327+
fs::symlink_dir(original_path, link).await
328+
} else {
329+
fs::symlink_file(original_path, link).await
330+
}
331+
},
332+
Inner::Chroot(root) => {
333+
let original_path = append(root.path(), original_path);
334+
let link_path = append(root.path(), link);
335+
if is_dir {
336+
fs::symlink_dir(original_path, link_path).await
337+
} else {
338+
fs::symlink_file(original_path, link_path).await
339+
}
340+
},
341+
Inner::Fake(_) => panic!("unimplemented"),
342+
}
343+
}
344+
307345
/// Creates a new symbolic link on the filesystem.
308346
///
309347
/// The `link` path will be a symbolic link pointing to the `original` path.
@@ -319,6 +357,52 @@ impl Fs {
319357
}
320358
}
321359

360+
/// Creates a new symbolic link on the filesystem.
361+
///
362+
/// The `link` path will be a symbolic link pointing to the `original` path.
363+
///
364+
/// This function works for both files and directories on all platforms.
365+
/// On Windows, it automatically detects whether the target is a file or directory
366+
/// and uses the appropriate system call.
367+
///
368+
/// This is a proxy to [`std::os::windows::fs::symlink_file`] or [`std::os::windows::fs::symlink_dir`] on Windows,
369+
/// and [`std::os::unix::fs::symlink`] on Unix.
370+
#[cfg(windows)]
371+
pub fn symlink_sync(&self, original: impl AsRef<Path>, link: impl AsRef<Path>) -> io::Result<()> {
372+
use inner::Inner;
373+
374+
let original_path = original.as_ref();
375+
376+
// Check if the original path exists and is a directory
377+
let is_dir = if let Ok(metadata) = std::fs::metadata(original_path) {
378+
metadata.is_dir()
379+
} else {
380+
// If the path doesn't exist, check if it ends with a path separator
381+
// This is a heuristic and not foolproof
382+
original_path.to_string_lossy().ends_with(['/', '\\'])
383+
};
384+
385+
match &self.0 {
386+
Inner::Real => {
387+
if is_dir {
388+
std::os::windows::fs::symlink_dir(original_path, link)
389+
} else {
390+
std::os::windows::fs::symlink_file(original_path, link)
391+
}
392+
},
393+
Inner::Chroot(root) => {
394+
let original_path = append(root.path(), original_path);
395+
let link_path = append(root.path(), link);
396+
if is_dir {
397+
std::os::windows::fs::symlink_dir(original_path, link_path)
398+
} else {
399+
std::os::windows::fs::symlink_file(original_path, link_path)
400+
}
401+
},
402+
Inner::Fake(_) => panic!("unimplemented"),
403+
}
404+
}
405+
322406
/// Query the metadata about a file without following symlinks.
323407
///
324408
/// This is a proxy to [`tokio::fs::symlink_metadata`]
@@ -330,7 +414,6 @@ impl Fs {
330414
///
331415
/// * The user lacks permissions to perform `metadata` call on `path`.
332416
/// * `path` does not exist.
333-
#[cfg(unix)]
334417
pub async fn symlink_metadata(&self, path: impl AsRef<Path>) -> io::Result<std::fs::Metadata> {
335418
use inner::Inner;
336419
match &self.0 {
@@ -420,6 +503,7 @@ impl Shim for Fs {
420503
/// Performs `a.join(b)`, except:
421504
/// - if `b` is an absolute path, then the resulting path will equal `/a/b`
422505
/// - if the prefix of `b` contains some `n` copies of a, then the resulting path will equal `/a/b`
506+
#[cfg(unix)]
423507
fn append(a: impl AsRef<Path>, b: impl AsRef<Path>) -> PathBuf {
424508
use std::ffi::OsString;
425509
use std::os::unix::ffi::OsStringExt;
@@ -437,6 +521,80 @@ fn append(a: impl AsRef<Path>, b: impl AsRef<Path>) -> PathBuf {
437521
PathBuf::from(OsString::from_vec(a.to_vec())).join(PathBuf::from(OsString::from_vec(b.to_vec())))
438522
}
439523

524+
#[cfg(windows)]
525+
fn append(a: impl AsRef<Path>, b: impl AsRef<Path>) -> PathBuf {
526+
let a_path = a.as_ref();
527+
let b_path = b.as_ref();
528+
529+
// Convert paths to string representation with normalized separators
530+
let a_str = a_path.to_string_lossy().replace('/', "\\");
531+
let b_str = b_path.to_string_lossy().replace('/', "\\");
532+
533+
// Handle drive letters in Windows paths
534+
let (b_drive, b_without_drive) = if b_str.len() >= 2 && b_str.chars().nth(1) == Some(':') {
535+
let drive = &b_str[..2];
536+
let rest = &b_str[2..];
537+
(Some(drive), rest.to_string())
538+
} else {
539+
(None, b_str)
540+
};
541+
542+
// If b has a drive letter and it's different from a's drive letter (if any),
543+
// we need to handle it specially
544+
let result_path = if let Some(b_drive) = b_drive {
545+
if a_str.starts_with(b_drive) {
546+
// Same drive, continue with normal processing
547+
let path_str = b_without_drive;
548+
549+
// Repeatedly strip the prefix if b starts with a
550+
let a_without_drive = if a_str.len() >= 2 && a_str.chars().nth(1) == Some(':') {
551+
&a_str[2..]
552+
} else {
553+
&a_str
554+
};
555+
556+
let mut b_normalized = path_str;
557+
while b_normalized.starts_with(a_without_drive) {
558+
b_normalized = b_normalized[a_without_drive.len()..].to_string();
559+
}
560+
561+
// Repeatedly strip leading backslashes
562+
while b_normalized.starts_with('\\') {
563+
b_normalized = b_normalized[1..].to_string();
564+
}
565+
566+
a_path.join(b_normalized)
567+
} else {
568+
// Different drives, handle specially
569+
let mut path_str = b_without_drive;
570+
571+
// Repeatedly strip leading backslashes
572+
while path_str.starts_with('\\') {
573+
path_str = path_str[1..].to_string();
574+
}
575+
576+
a_path.join(path_str)
577+
}
578+
} else {
579+
// No drive letter in b, proceed with normal processing
580+
let mut b_normalized = b_without_drive;
581+
582+
// Repeatedly strip the prefix if b starts with a
583+
while b_normalized.starts_with(&a_str) {
584+
b_normalized = b_normalized[a_str.len()..].to_string();
585+
}
586+
587+
// Repeatedly strip leading backslashes
588+
while b_normalized.starts_with('\\') {
589+
b_normalized = b_normalized[1..].to_string();
590+
}
591+
592+
a_path.join(b_normalized)
593+
};
594+
595+
result_path
596+
}
597+
440598
#[cfg(test)]
441599
mod tests {
442600
use super::*;
@@ -478,10 +636,66 @@ mod tests {
478636
assert_eq!(append($a, $b), PathBuf::from($expected));
479637
};
480638
}
481-
assert_append!("/abc/test", "/test", "/abc/test/test");
482-
assert_append!("/tmp/.dir", "/tmp/.dir/home/myuser", "/tmp/.dir/home/myuser");
483-
assert_append!("/tmp/.dir", "/tmp/hello", "/tmp/.dir/tmp/hello");
484-
assert_append!("/tmp/.dir", "/tmp/.dir/tmp/.dir/home/user", "/tmp/.dir/home/user");
639+
#[cfg(unix)]
640+
{
641+
assert_append!("/abc/test", "/test", "/abc/test/test");
642+
assert_append!("/tmp/.dir", "/tmp/.dir/home/myuser", "/tmp/.dir/home/myuser");
643+
assert_append!("/tmp/.dir", "/tmp/hello", "/tmp/.dir/tmp/hello");
644+
assert_append!("/tmp/.dir", "/tmp/.dir/tmp/.dir/home/user", "/tmp/.dir/home/user");
645+
}
646+
647+
#[cfg(windows)]
648+
{
649+
// Basic path joining
650+
assert_append!("C:\\abc\\test", "test", "C:\\abc\\test\\test");
651+
652+
// Absolute path handling
653+
assert_append!("C:\\abc\\test", "C:\\test", "C:\\abc\\test\\test");
654+
655+
// Nested path handling
656+
assert_append!(
657+
"C:\\tmp\\.dir",
658+
"C:\\tmp\\.dir\\home\\myuser",
659+
"C:\\tmp\\.dir\\home\\myuser"
660+
);
661+
662+
// Similar prefix handling
663+
assert_append!("C:\\tmp\\.dir", "C:\\tmp\\hello", "C:\\tmp\\.dir\\tmp\\hello");
664+
665+
// Multiple prefixes handling
666+
assert_append!(
667+
"C:\\tmp\\.dir",
668+
"C:\\tmp\\.dir\\tmp\\.dir\\home\\user",
669+
"C:\\tmp\\.dir\\home\\user"
670+
);
671+
672+
// Different drive handling
673+
assert_append!("C:\\tmp", "D:\\data", "C:\\tmp\\data");
674+
675+
// Forward slash handling in Windows paths
676+
assert_append!("C:\\tmp", "C:/data/file.txt", "C:\\tmp\\data\\file.txt");
677+
678+
// UNC path handling
679+
assert_append!(
680+
"C:\\tmp",
681+
"\\\\server\\share\\file.txt",
682+
"C:\\tmp\\server\\share\\file.txt"
683+
);
684+
685+
// Path with spaces
686+
assert_append!(
687+
"C:\\Program Files",
688+
"App Data\\config.ini",
689+
"C:\\Program Files\\App Data\\config.ini"
690+
);
691+
692+
// Path with special characters
693+
assert_append!(
694+
"C:\\Users",
695+
"user.name@domain.com\\Documents",
696+
"C:\\Users\\user.name@domain.com\\Documents"
697+
);
698+
}
485699
}
486700

487701
#[tokio::test]
@@ -608,4 +822,58 @@ mod tests {
608822
fs.create_new("my_file.txt").await.unwrap();
609823
assert!(fs.create_new("my_file.txt").await.is_err());
610824
}
825+
826+
#[tokio::test]
827+
#[cfg(windows)]
828+
async fn test_unified_symlink_windows() {
829+
let dir = tempfile::tempdir().unwrap();
830+
let fs = Fs::new();
831+
832+
// Create a test file
833+
let file_path = dir.path().join("test_file.txt");
834+
fs.write(&file_path, "test content").await.unwrap();
835+
836+
// Create a test directory
837+
let dir_path = dir.path().join("test_dir");
838+
fs.create_dir(&dir_path).await.unwrap();
839+
840+
// Test symlink to file
841+
let file_link_path = dir.path().join("file_link");
842+
match fs.symlink(&file_path, &file_link_path).await {
843+
Ok(_) => {
844+
// If we have permission to create symlinks, run the full test
845+
assert_eq!(fs.read_to_string(&file_link_path).await.unwrap(), "test content");
846+
847+
// Test symlink to directory
848+
let dir_link_path = dir.path().join("dir_link");
849+
fs.symlink(&dir_path, &dir_link_path).await.unwrap();
850+
assert!(fs.try_exists(&dir_link_path).await.unwrap());
851+
852+
// Test symlink_sync to file
853+
let file_link_sync_path = dir.path().join("file_link_sync");
854+
fs.symlink_sync(&file_path, &file_link_sync_path).unwrap();
855+
assert_eq!(fs.read_to_string(&file_link_sync_path).await.unwrap(), "test content");
856+
857+
// Test symlink_sync to directory
858+
let dir_link_sync_path = dir.path().join("dir_link_sync");
859+
fs.symlink_sync(&dir_path, &dir_link_sync_path).unwrap();
860+
assert!(fs.try_exists(&dir_link_sync_path).await.unwrap());
861+
862+
// Clean up
863+
fs.remove_file(&file_link_path).await.unwrap();
864+
fs.remove_file(&file_link_sync_path).await.unwrap();
865+
fs.remove_dir_all(&dir_link_path).await.unwrap();
866+
fs.remove_dir_all(&dir_link_sync_path).await.unwrap();
867+
},
868+
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied || e.raw_os_error() == Some(1314) => {
869+
// Error code 1314 is "A required privilege is not held by the client"
870+
// Skip the test if we don't have permission to create symlinks
871+
println!("Skipping test_unified_symlink_windows: requires admin privileges on Windows");
872+
},
873+
Err(e) => {
874+
// For other errors, fail the test
875+
panic!("Unexpected error creating symlink: {}", e);
876+
},
877+
}
878+
}
611879
}

0 commit comments

Comments
 (0)