sources/Google.Solutions.Terminal/SftpFileSystem.cs (264 lines of code) (raw):

// // Copyright 2022 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you 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. // using Google.Solutions.Common.Util; using Google.Solutions.Mvvm.Controls; using Google.Solutions.Mvvm.Shell; using Google.Solutions.Platform.Interop; using Google.Solutions.Ssh; using System; using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; #pragma warning disable CS0067 // The event ... is never used namespace Google.Solutions.Terminal { /// <summary> /// Implements IFileSystem on a SFTP channel. /// </summary> internal sealed class SftpFileSystem : IFileSystem, IDisposable { private readonly FileTypeCache fileTypeCache; private readonly ISftpChannel channel; private static readonly Regex configFileNamePattern = new Regex("co?ni?f(ig)?$"); /// <summary> /// Default permissions to apply to new files. /// </summary> public FilePermissions DefaultFilePermissions { get; set; } = FilePermissions.OwnerRead | FilePermissions.OwnerWrite; private static readonly DateTime Epoch = DateTimeOffset.FromUnixTimeSeconds(0).DateTime; /// <summary> /// The file system root (/) on the server. /// </summary> internal IFileItem Drive { get; } /// <summary> /// The user's home directory. /// </summary> internal IFileItem Home { get; } /// <summary> /// Map SFTP file attributes to a file type. /// </summary> internal FileType MapFileType(SftpFileInfo sftpFile) { if (sftpFile.IsDirectory) { return this.fileTypeCache.Lookup( sftpFile.Name, FileAttributes.Directory, FileType.IconFlags.None); } else if (sftpFile.Permissions.HasFlag(FilePermissions.SymbolicLink)) { // // Treat like an LNK file. // // NB. We can't tell whether the symlink points to a directory // or not, that would require resolving the link. So we treat // all symlinks like files. // return this.fileTypeCache.Lookup( ".lnk", FileAttributes.Normal, FileType.IconFlags.None); } else if (sftpFile.Permissions.HasFlag(FilePermissions.OwnerExecute) || sftpFile.Permissions.HasFlag(FilePermissions.GroupExecute) || sftpFile.Permissions.HasFlag(FilePermissions.OtherExecute)) { // // Treat like an EXE file. // return this.fileTypeCache.Lookup( ".exe", FileAttributes.Normal, FileType.IconFlags.None); } else if (configFileNamePattern.IsMatch(sftpFile.Name)) { // // Treat like an INI file. // return this.fileTypeCache.Lookup( ".ini", FileAttributes.Normal, FileType.IconFlags.None); } else { // // Lookup file type using Shell. // return this.fileTypeCache.Lookup( Win32Filename.IsValidFilename(sftpFile.Name) ? sftpFile.Name : "file", FileAttributes.Normal, FileType.IconFlags.None); } } /// <summary> /// Translate SFTP file attributes to Win32 attributes. /// </summary> internal static FileAttributes MapFileAttributes( string name, bool isDirectory, FilePermissions permissions) { var attributes = (FileAttributes)0; if (isDirectory) { attributes |= FileAttributes.Directory; } if (name.StartsWith(".")) { attributes |= FileAttributes.Hidden; } if (permissions.IsLink()) { attributes |= FileAttributes.ReparsePoint; } if (permissions.IsSocket() || permissions.IsFifo() || permissions.IsCharacterDevice() || permissions.IsBlockDevice()) { attributes |= FileAttributes.Device; } return attributes == 0 ? FileAttributes.Normal : attributes; } public SftpFileSystem(ISftpChannel channel) { this.channel = channel.ExpectNotNull(nameof(channel)); this.fileTypeCache = new FileTypeCache(); // // Initialize pseudo-directories. // this.Root = new FileItem( new FileType( "Server", false, StockIcons.GetIcon( StockIcons.IconId.Server, StockIcons.IconSize.Small)), "Server", string.Empty, FileAttributes.Directory | FileAttributes.ReadOnly, Epoch, 0, string.Empty) { IsExpanded = true }; this.Home = new FileItem( new FileType( "Home", false, StockIcons.GetIcon( StockIcons.IconId.Folder, StockIcons.IconSize.Small)), "Home", ".", FileAttributes.Directory, Epoch, 0, string.Empty); this.Drive = new FileItem( new FileType( "Drive", false, StockIcons.GetIcon( StockIcons.IconId.DriveFixed, StockIcons.IconSize.Small)), "File system root", "/.", FileAttributes.Directory, Epoch, 0, string.Empty); } //--------------------------------------------------------------------- // IFileSystem. //--------------------------------------------------------------------- /// <summary> /// The "Server" node, root of the virtual file system. /// </summary> public IFileItem Root { get; } public async Task<ObservableCollection<IFileItem>> ListFilesAsync( IFileItem directory) { directory.ExpectNotNull(nameof(directory)); Debug.Assert(!directory.Type.IsFile); if (directory == this.Root) { // // Return a pseudo-directory listing. // return new ObservableCollection<IFileItem>() { this.Home, this.Drive, }; } else { var sftpFiles = await this.channel .ListFilesAsync(directory.Path) .ConfigureAwait(false); // // NB. SFTP returns files/directories in arbitrary order. // var filteredSftpFiles = sftpFiles .Where(f => f.Name != "." && f.Name != "..") .OrderBy(f => !f.IsDirectory).ThenBy(f => f.Name) .Select(f => new FileItem( (FileItem)directory, MapFileType(f), f.Name, MapFileAttributes(f.Name, f.IsDirectory, f.Permissions), f.LastModifiedDate, f.Size, f.Permissions.ToListFormat())) .ToList(); return new ObservableCollection<IFileItem>(filteredSftpFiles); } } public Task<Stream> OpenFileAsync( IFileItem file, FileAccess access) { if (file == this.Root) { throw new UnauthorizedAccessException(); } Precondition.Expect(file.Type.IsFile, $"{file.Name} is not a file"); return this.channel.CreateFileAsync( file.Path, FileMode.Open, access, FilePermissions.None); } public Task<Stream> OpenFileAsync( IFileItem directory, string name, FileMode mode, FileAccess access) { if (directory == this.Root) { throw new UnauthorizedAccessException(); } Precondition.Expect(!directory.Type.IsFile, $"{directory.Name} is not a directory"); Precondition.Expect(!name.Contains("/"), "Name must not be a path"); return this.channel.CreateFileAsync( $"{directory.Path}/{name}", mode, access, this.DefaultFilePermissions); } //--------------------------------------------------------------------- // IDisposable. //--------------------------------------------------------------------- public void Dispose() { this.fileTypeCache.Dispose(); this.channel?.Dispose(); } //--------------------------------------------------------------------- // Inner classes. //--------------------------------------------------------------------- private class FileItem : IFileItem { public event PropertyChangedEventHandler? PropertyChanged; internal FileItem( FileType type, string name, string path, FileAttributes attributes, DateTime lastModified, ulong size, string access) { this.Type = type; this.Name = name; this.Path = path; this.Attributes = attributes; this.LastModified = lastModified; this.Size = size; this.Access = access; } internal FileItem( FileItem? parent, FileType type, string name, FileAttributes attributes, DateTime lastModified, ulong size, string access) : this( type, name, parent != null ? $"{parent.Path}/{name}" : name, attributes, lastModified, size, access) { } public FileType Type { get; } public string Name { get; } public FileAttributes Attributes { get; } public DateTime LastModified { get; } public ulong Size { get; } public string Path { get; } public string Access { get; } public bool IsExpanded { get; set; } } } }